diff --git a/Autotests/unit/test_llm_budget.py b/Autotests/unit/test_llm_budget.py index 95904e74..ea5eb168 100644 --- a/Autotests/unit/test_llm_budget.py +++ b/Autotests/unit/test_llm_budget.py @@ -26,12 +26,17 @@ def _exec(name, path, register): return module +# Values the stubbed config module returns for keys the tests control; +# anything else falls back to the caller's default. +CONFIG_STUB = {} + + def _load_modules(): openai_stub = types.ModuleType("openai") openai_stub.OpenAI = object config_stub = types.ModuleType("config") - config_stub.config_get_by_key = lambda key, default=None: default + config_stub.config_get_by_key = lambda key, default=None: CONFIG_STUB.get(key, default) stubs = {"openai": openai_stub, "config": config_stub} saved = {name: sys.modules.get(name) for name in list(stubs) + ["providers", "lib_llm_ext"]} @@ -232,6 +237,78 @@ def test_openrouter_reasoning_body(effort, want): assert create.calls[0]["extra_body"]["reasoning"] == want +@pytest.mark.parametrize("effort, want", [ + ("medium", "medium"), + ("none", "none"), +]) +def test_base_provider_forwards_reasoning_effort(effort, want): + CONFIG_STUB["supportsReasoningEffort"] = True + try: + create = FakeCreate(chat_response([tool_call("call_1", "send", '{"content": "hi"}')], "tool_calls", + completion_tokens=40, reasoning_tokens=30)) + make_anthropic(create).chat(make_request(reasoning=effort)) + assert create.calls[0]["extra_body"]["reasoning_effort"] == want + finally: + del CONFIG_STUB["supportsReasoningEffort"] + + +def test_base_provider_omits_reasoning_when_unset(): + create = FakeCreate(chat_response([tool_call("call_1", "send", '{"content": "hi"}')], "tool_calls", + completion_tokens=40, reasoning_tokens=30)) + make_anthropic(create).chat(make_request(reasoning=None)) + assert "extra_body" not in create.calls[0] + + +def test_base_provider_omits_reasoning_when_effort_unsupported(): + CONFIG_STUB["supportsReasoningEffort"] = False + try: + create = FakeCreate(chat_response([tool_call("call_1", "send", '{"content": "hi"}')], "tool_calls", + completion_tokens=40, reasoning_tokens=30)) + make_anthropic(create).chat(make_request(reasoning="medium")) + assert "extra_body" not in create.calls[0] + finally: + del CONFIG_STUB["supportsReasoningEffort"] + + +def test_base_provider_sends_reasoning_when_effort_supported(): + CONFIG_STUB["supportsReasoningEffort"] = True + try: + create = FakeCreate(chat_response([tool_call("call_1", "send", '{"content": "hi"}')], "tool_calls", + completion_tokens=40, reasoning_tokens=30)) + make_anthropic(create).chat(make_request(reasoning="medium")) + assert create.calls[0]["extra_body"]["reasoning_effort"] == "medium" + finally: + del CONFIG_STUB["supportsReasoningEffort"] + +@pytest.mark.parametrize("flag, want_sent", [ + ("false", False), + ("False", False), + (" off ", False), + ("0", False), + ("true", True), + ("yes", True), + (True, True), +]) +def test_base_provider_reads_the_flag_from_cli_and_env_strings(flag, want_sent): + """Command line and environment values of the flag arrive as strings.""" + CONFIG_STUB["supportsReasoningEffort"] = flag + try: + create = FakeCreate(chat_response([tool_call("call_1", "send", '{"content": "hi"}')], "tool_calls", + completion_tokens=40, reasoning_tokens=30)) + make_anthropic(create).chat(make_request(reasoning="medium")) + assert ("extra_body" in create.calls[0]) == want_sent + finally: + del CONFIG_STUB["supportsReasoningEffort"] + + +def test_base_provider_omits_reasoning_when_the_flag_is_unset(): + """Configurations written before this flag existed keep sending what they sent.""" + create = FakeCreate(chat_response([tool_call("call_1", "send", '{"content": "hi"}')], "tool_calls", + completion_tokens=40, reasoning_tokens=30)) + make_anthropic(create).chat(make_request(reasoning="medium")) + assert "extra_body" not in create.calls[0] + + def test_usage_is_logged_at_info(caplog): caplog.set_level(logging.INFO) create = FakeCreate(chat_response([tool_call("call_1", "send", '{"content": "hi"}')], "tool_calls", diff --git a/config/config.yaml b/config/config.yaml index 8cbf65da..990a4364 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -14,6 +14,12 @@ provider: Anthropic maxOutputToken: 6000 # Reasoning-effort hint passed to the provider reasoningMode: medium +# Set true to send the hint as the reasoning_effort request parameter, for +# OpenAI-compatible servers that accept it (llama.cpp, vLLM, Strata, ...). +# Default false: existing configurations keep sending requests exactly as +# before, so models that reject the parameter (e.g. non-reasoning OpenAI +# models served through the OpenAIAPI provider) are unaffected. +supportsReasoningEffort: false # How long idle before the next scheduled wake-up (seconds) wakeupInterval: 600 # Path to the logger configuration file diff --git a/docs/reference-configuration.md b/docs/reference-configuration.md index 84c0c5ed..f93f2876 100644 --- a/docs/reference-configuration.md +++ b/docs/reference-configuration.md @@ -21,6 +21,7 @@ This resolves the value through the Python configuration layer, using command-li | `provider` | `Anthropic` | LLM provider — `Anthropic`, `OpenAI`, `ASICloud`, or `ASIOne`. | | `maxOutputToken` | 6000 | Output cap passed to the provider. | | `reasoningMode` | `medium` | Reasoning-effort hint passed to the provider. | +| `supportsReasoningEffort` | `false` | Send the hint as the `reasoning_effort` request parameter, for OpenAI-compatible servers that accept it (`llama.cpp`, `vLLM`, `Strata`, ...). Command-line and environment values are read as booleans, so `supportsReasoningEffort=false` turns it off. | | `wakeupInterval` | 600 (seconds) | How long idle before the next scheduled wake-up. | ## Memory (`src/memory.metta`, `initMemory`) diff --git a/providers/lib_llm_ext.py b/providers/lib_llm_ext.py index bd1b216f..3d2fbca7 100644 --- a/providers/lib_llm_ext.py +++ b/providers/lib_llm_ext.py @@ -209,6 +209,16 @@ def _merge_dicts(base: Optional[Dict[str, Any]], extra: Optional[Dict[str, Any]] merged.update(extra or {}) return merged + +def _flag_is_on(value: Any) -> bool: + """Read a boolean configuration flag. Command line and environment values + arrive as strings, in which "false" is a non-empty string and thus truthy, + so the false spellings are listed instead of relying on truthiness.""" + if isinstance(value, str): + return value.strip().lower() not in ("false", "no", "off", "0", "") + return bool(value) + + class AbstractAIProvider: def __init__(self, name: str): self._name = name @@ -338,13 +348,23 @@ def convert_tool(self, tool: LLMTool) -> Dict: } def convert_request(self, request: LLMRequest) -> Dict[str, Any]: - return { + result = { "model": self._model_name, "messages": [self.convert_message(msg) for msg in request.messages], "max_tokens": request.max_tokens, "tools": [self.convert_tool(tool) for tool in request.tools], "tool_choice": "required", } + reasoning = getattr(request, "reasoning_mode", None) + if reasoning and _flag_is_on(config_get_by_key("supportsReasoningEffort", False)): + # OpenAI-compatible servers (llama.cpp, vLLM, Strata, ...) accept + # reasoning_effort; "none" disables thinking. Plain OpenAI chat + # completions endpoints ignore unknown fields. Models that reject + # the parameter (e.g. non-reasoning OpenAI models) need it omitted, + # so the flag defaults to false; set supportsReasoningEffort: true + # on servers that accept it. + result["extra_body"] = {"reasoning_effort": str(reasoning).lower()} + return result def convert_response(self, raw): response = LLMResponse()