fix(llm/openai): normalize tool_choice "any" → "required"

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.
This commit is contained in:
thrialectics 2026-04-30 12:08:56 -04:00
parent f37338b855
commit ca96c8a5fa
2 changed files with 57 additions and 0 deletions

View File

@ -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 (

View File

@ -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",
[