From ed58d0d61f8870b1961f073afe49fedf3fe9b47c Mon Sep 17 00:00:00 2001 From: Ivan Neto Date: Fri, 4 Sep 2026 14:37:00 -0300 Subject: [PATCH] fix: send reasoning_effort="none" so gpt-5.6 models accept function tools OpenAI rejects function tools on /v1/chat/completions for every gpt-5.6 variant while reasoning is on: Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'. That makes gpt-5.6 unusable for the extraction agent, which is a tool loop on chat/completions, and there is currently no way to reach the parameter from configuration. The failure is easy to misread: non-tool calls to the same model keep succeeding, so extraction fails while generation looks healthy. LiteLLM compounds it by masking the provider message behind a TypeError of its own (BadRequestError.__init__() missing 2 required positional arguments: 'model' and 'llm_provider'). Add TOOLS_REQUIRE_REASONING_DISABLED_MODELS alongside the existing TEMPERATURE_RESTRICTED_MODELS set, with a matching _tools_require_reasoning_disabled helper that strips provider routing prefixes the same way. When a request carries tools and the model is in that set, default reasoning_effort to "none". setdefault, placed before params.update(kwargs), so an explicit caller-supplied reasoning_effort still wins. Non-tool calls to gpt-5.6 keep reasoning enabled, and no other model is affected. --- .../server/llm/_litellm_text_generation.py | 33 +++++++ tests/server/llm/test_litellm_client_unit.py | 90 +++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/reflexio/server/llm/_litellm_text_generation.py b/reflexio/server/llm/_litellm_text_generation.py index 2f2ab54d..5c332053 100644 --- a/reflexio/server/llm/_litellm_text_generation.py +++ b/reflexio/server/llm/_litellm_text_generation.py @@ -225,6 +225,17 @@ class TextGenerationMixin: "gemini-3-pro-preview", } + # Models that reject function tools on /v1/chat/completions while reasoning is + # on. The API answers 400 with: + # "Function tools with reasoning_effort are not supported for gpt-5.6-luna + # in /v1/chat/completions. To use function tools, use /v1/responses or set + # reasoning_effort to 'none'." + # Sending reasoning_effort="none" alongside the tools keeps these models usable + # on the completions path. + TOOLS_REQUIRE_REASONING_DISABLED_MODELS = { + "gpt-5.6", + } + # Base-owned attributes these methods read (init'd in the facade ``__init__``). config: "LiteLLMConfig" logger: logging.Logger @@ -569,6 +580,10 @@ def _build_completion_params( params["allowed_openai_params"] = allowed_openai_params if tools is not None: params["tools"] = tools + if self._tools_require_reasoning_disabled(actual_model): + # setdefault, and placed before params.update(kwargs), so an + # explicit caller-supplied reasoning_effort still wins. + params.setdefault("reasoning_effort", "none") if tool_choice is not None: params["tool_choice"] = tool_choice @@ -1737,6 +1752,24 @@ def encode_image_to_base64(self, image_path: str) -> tuple[str, str]: except ImageEncodingError as exc: raise LiteLLMClientError(str(exc)) from exc + def _tools_require_reasoning_disabled(self, model: str) -> bool: + """Check whether a model needs reasoning off to accept function tools. + + Args: + model: Model name to check. + + Returns: + True if the model rejects function tools unless reasoning_effort is + "none" on the chat/completions path. + """ + model_lower = model.lower() + # Strip provider routing prefixes (e.g., "openrouter/openai/gpt-5.6-luna"). + model_name = model_lower.rsplit("/", 1)[-1] + return any( + model_name.startswith(restricted) or model_name == restricted + for restricted in self.TOOLS_REQUIRE_REASONING_DISABLED_MODELS + ) + def _is_temperature_restricted_model(self, model: str) -> bool: """ Check if a model has temperature restrictions (e.g., GPT-5 and Gemini 3 models only support temperature=1.0). diff --git a/tests/server/llm/test_litellm_client_unit.py b/tests/server/llm/test_litellm_client_unit.py index 37dc68ce..a11d7c5b 100644 --- a/tests/server/llm/test_litellm_client_unit.py +++ b/tests/server/llm/test_litellm_client_unit.py @@ -4510,3 +4510,93 @@ class Model(BaseModel): assert errors == ("count: int_parsing",) assert all("customer data" not in error for error in errors) + + +# =================================================================== +# Function tools on models that reject reasoning +# =================================================================== + + +_TOOLS = [ + { + "type": "function", + "function": { + "name": "finish", + "parameters": {"type": "object", "properties": {}}, + }, + } +] + + +class TestToolsRequireReasoningDisabled: + """Tests for _tools_require_reasoning_disabled and its param injection.""" + + @pytest.fixture() + def client(self): + return _build_client() + + @pytest.mark.parametrize( + "model", + [ + "gpt-5.6", + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "GPT-5.6-Luna", + ], + ) + def test_affected_models(self, client, model): + assert client._tools_require_reasoning_disabled(model) is True + + @pytest.mark.parametrize( + "model", + [ + "gpt-5.5", + "gpt-5.4-mini", + "gpt-5-nano", + "gpt-4o", + "claude-3-5-sonnet", + "gemini-3-flash-preview", + ], + ) + def test_unaffected_models(self, client, model): + assert client._tools_require_reasoning_disabled(model) is False + + def test_provider_prefix_stripped(self, client): + assert ( + client._tools_require_reasoning_disabled("openrouter/openai/gpt-5.6-luna") + is True + ) + + def _capture(self, model, **call_kwargs): + calls: list[dict[str, Any]] = [] + + def fake_completion(**kwargs): + calls.append(kwargs) + return _make_completion_response("ok") + + client = _build_client(LiteLLMConfig(model=model)) + with patch("litellm.completion", side_effect=fake_completion): + client.generate_chat_response( + messages=[{"role": "user", "content": "test"}], **call_kwargs + ) + assert len(calls) == 1 + return calls[0] + + def test_tools_on_affected_model_disable_reasoning(self): + kwargs = self._capture("gpt-5.6-luna", tools=_TOOLS) + assert kwargs["reasoning_effort"] == "none" + + def test_no_tools_leaves_reasoning_alone(self): + """Only the tools path is affected — plain calls keep reasoning enabled.""" + kwargs = self._capture("gpt-5.6-luna") + assert "reasoning_effort" not in kwargs + + def test_unaffected_model_with_tools_is_untouched(self): + kwargs = self._capture("gpt-5.5", tools=_TOOLS) + assert "reasoning_effort" not in kwargs + + def test_explicit_reasoning_effort_wins(self): + """setdefault, not assignment: a caller-supplied value must survive.""" + kwargs = self._capture("gpt-5.6-luna", tools=_TOOLS, reasoning_effort="low") + assert kwargs["reasoning_effort"] == "low"