From ca96c8a5fac49307e4688a7693a4d4e9dc639f7a Mon Sep 17 00:00:00 2001 From: thrialectics <203729272+thrialectics@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:08:56 -0400 Subject: [PATCH] =?UTF-8?q?fix(llm/openai):=20normalize=20tool=5Fchoice=20?= =?UTF-8?q?"any"=20=E2=86=92=20"required"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI rejects tool_choice="any" with a 400 (only accepts none/auto/required), but Anthropic and Gemini backends both treat "any" and "required" as equivalent (anthropic.py:321, gemini.py:568), and tool_loop.py:382 assumes the same. Callers passing "any" — including via env/config overrides not covered by the default-only patch in #630 — would still hit the error. Map "any" → "required" inside the OpenAI backend so callers can use either spelling without provider-specific awareness. --- src/llm/backends/openai.py | 4 ++ tests/llm/test_backends/test_openai.py | 53 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index 1e01e78a..c6e49f50 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -309,6 +309,10 @@ class OpenAIBackend: if tools: params["tools"] = self._convert_tools(tools) if tool_choice is not None: + # OpenAI accepts only "none" | "auto" | "required" (or a function spec). + # Other backends use "any" with the same semantics as "required". + if tool_choice == "any": + tool_choice = "required" params["tool_choice"] = tool_choice if extra_params: for key in ( diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index 81838202..862fbfa9 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -230,6 +230,59 @@ async def test_openai_backend_converts_anthropic_style_tools() -> None: assert call["tool_choice"] == "required" +@pytest.mark.asyncio +async def test_openai_backend_normalizes_any_tool_choice_to_required() -> None: + """OpenAI rejects tool_choice="any" (Anthropic/Gemini's spelling for the + same semantics OpenAI calls "required"). The backend must translate it + so callers can pass either spelling without provider-specific awareness. + """ + client = Mock() + client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace( + content="ok", + tool_calls=[], + reasoning_details=[], + ), + ) + ], + usage=SimpleNamespace( + prompt_tokens=10, + completion_tokens=5, + prompt_tokens_details=None, + ), + ) + ) + + backend = OpenAIBackend(client) + await backend.complete( + model="gpt-4.1", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + tools=[ + { + "name": "get_weather", + "description": "Lookup weather", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + } + ], + tool_choice="any", + ) + + await_args = client.chat.completions.create.await_args + if await_args is None: + raise AssertionError("Expected OpenAI create call") + call = await_args.kwargs + assert call["tool_choice"] == "required" + + @pytest.mark.parametrize( "model", [