From 4625305a300d3fd34d857c0e8b90487a1fffd05e Mon Sep 17 00:00:00 2001 From: alex-clawd Date: Sun, 26 Jul 2026 03:30:33 -0700 Subject: [PATCH] fix(openai): force reasoning_effort=none even when the caller sent none at all The learned fast path only rewrote `reasoning_effort` when it was already present and not "none", so a learned model with the parameter absent kept sending nothing -- which these models reject exactly like "high", because they apply a server-side default. The cache recorded the conflict but every call still paid the 400. Reproduced before fixing: LLM(model='openai/gpt-5.6-sol') # no reasoning_effort anywhere 1st call -> 400, recovered, model learned 2nd call -> reasoning_effort ABSENT # 400 again, forever Now "none" is forced whenever the model is known and the value isn't already "none". Same call after the fix sends reasoning_effort=none. `_retry_params_without_reasoning_effort` already handled the absent case correctly; only the fast path was wrong. tests/llms/openai -> 160 passed, 1 skipped. Ruff + mypy clean. --- .../src/crewai/llms/providers/openai/completion.py | 9 +++++---- .../openai/test_gpt56_tools_reasoning_effort.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index e2d4c3e40..478cec15e 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -1929,14 +1929,15 @@ class OpenAICompletion(BaseLLM): # request; the first occurrence is recovered in `_call_completions`. # Checked after the tools block on purpose: `reasoning_effort` can also # arrive through `additional_params`, which bypasses the typed field. + # An absent `reasoning_effort` is not safe either: these models apply a + # server-side default, so sending nothing is rejected exactly like sending + # "high". Only an explicit "none" is accepted alongside tools. if ( params.get("tools") - and params.get("reasoning_effort") not in (None, "none") + and params.get("reasoning_effort") != "none" and not self._supports_reasoning_effort_with_tools(self.model) ): - requested = params["reasoning_effort"] - # Explicit "none" rather than removing the key: gpt-5.6-* apply a - # server-side default, so omitting it fails the same way. + requested = params.get("reasoning_effort") params["reasoning_effort"] = "none" logging.debug( 'Forcing reasoning_effort="none" (requested %r) for model %r: ' diff --git a/lib/crewai/tests/llms/openai/test_gpt56_tools_reasoning_effort.py b/lib/crewai/tests/llms/openai/test_gpt56_tools_reasoning_effort.py index 0e25beaa9..b612c87f6 100644 --- a/lib/crewai/tests/llms/openai/test_gpt56_tools_reasoning_effort.py +++ b/lib/crewai/tests/llms/openai/test_gpt56_tools_reasoning_effort.py @@ -108,6 +108,20 @@ class TestLearnedFastPath: assert params["tools"] assert params["tool_choice"] == "auto" + def test_absent_reasoning_effort_is_also_forced_to_none(self): + """Sending nothing is rejected exactly like sending "high". + + These models apply a server-side default, so a learned model with no + `reasoning_effort` set would 400 on every call and never benefit from the + cache. + """ + llm = build("gpt-5.6-sol") + llm._remember_reasoning_effort_conflict() + + params = llm._prepare_completion_params(MESSAGES, tools=TOOLS) + + assert params["reasoning_effort"] == "none" + def test_learning_applies_to_the_additional_params_leak(self): """additional_params bypasses the typed field, so it must be checked too.""" llm = build("gpt-5.5", additional_params={"reasoning_effort": "medium"})