diff --git a/headroom/backends/anyllm.py b/headroom/backends/anyllm.py index 468d20450..1af994e47 100644 --- a/headroom/backends/anyllm.py +++ b/headroom/backends/anyllm.py @@ -25,6 +25,44 @@ except ImportError: AnyLLM = None # type: ignore +def _convert_anthropic_tool(tool: dict[str, Any]) -> dict[str, Any]: + """Convert an Anthropic tool definition to the OpenAI function shape. + + any-llm speaks OpenAI, so an Anthropic ``{name, description, input_schema}`` + tool must become ``{type: function, function: {name, description, + parameters}}`` before it is forwarded, or the provider ignores/rejects the + tools array and the model never calls a tool. Mirrors the LiteLLM backend's + converter so both OpenAI-compatible backends send the same shape. + """ + func: dict[str, Any] = {"name": tool.get("name", "")} + if "description" in tool: + func["description"] = tool["description"] + if "input_schema" in tool: + func["parameters"] = tool["input_schema"] + return {"type": "function", "function": func} + + +def _convert_tool_choice(choice: Any) -> Any: + """Convert an Anthropic ``tool_choice`` to the OpenAI shape (mirrors LiteLLM). + + Anthropic: ``{"type": "auto"}``, ``{"type": "any"}``, ``{"type": "tool", + "name": ...}``. OpenAI: ``"auto"``, ``"required"``, ``{"type": "function", + "function": {"name": ...}}``. Passing the raw Anthropic dict through makes + the provider reject or ignore it. + """ + if isinstance(choice, str): + return choice + if isinstance(choice, dict): + choice_type = choice.get("type", "auto") + if choice_type == "auto": + return "auto" + if choice_type == "any": + return "required" + if choice_type == "tool": + return {"type": "function", "function": {"name": choice.get("name", "")}} + return "auto" + + class AnyLLMBackend(Backend): """Backend using any-llm for multi-provider support.""" @@ -251,9 +289,9 @@ class AnyLLMBackend(Backend): if "stop_sequences" in body: kwargs["stop"] = body["stop_sequences"] if "tools" in body: - kwargs["tools"] = body["tools"] + kwargs["tools"] = [_convert_anthropic_tool(t) for t in body["tools"]] if "tool_choice" in body: - kwargs["tool_choice"] = body["tool_choice"] + kwargs["tool_choice"] = _convert_tool_choice(body["tool_choice"]) logger.debug(f"any-llm request: provider={self.provider}, model={original_model}") @@ -301,9 +339,9 @@ class AnyLLMBackend(Backend): if "stop_sequences" in body: kwargs["stop"] = body["stop_sequences"] if "tools" in body: - kwargs["tools"] = body["tools"] + kwargs["tools"] = [_convert_anthropic_tool(t) for t in body["tools"]] if "tool_choice" in body: - kwargs["tool_choice"] = body["tool_choice"] + kwargs["tool_choice"] = _convert_tool_choice(body["tool_choice"]) msg_id = f"msg_{uuid.uuid4().hex[:24]}" diff --git a/tests/test_backend_anyllm.py b/tests/test_backend_anyllm.py index 3d56fcb0a..3e1e1a8e1 100644 --- a/tests/test_backend_anyllm.py +++ b/tests/test_backend_anyllm.py @@ -293,6 +293,81 @@ async def test_send_message_builds_anthropic_response(monkeypatch: pytest.Monkey assert instance.calls[0]["stop"] == ["END"] +@pytest.mark.asyncio +async def test_send_message_converts_anthropic_tools_and_tool_choice( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Anthropic tools/tool_choice must reach any-llm in the OpenAI shape. + + any-llm speaks OpenAI; forwarding the raw Anthropic ``input_schema`` tool and + the ``{"type": ...}`` tool_choice makes the provider ignore or reject them, + so the model never calls a tool. Regression for tool use silently not + working on the any-llm backend. + """ + backend, instance = make_backend(monkeypatch) + instance.response = make_response(make_choice("ok", "stop")) + + await backend.send_message( + { + "model": "claude", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "name": "get_weather", + "description": "look up weather", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + } + ], + "tool_choice": {"type": "any"}, + }, + {}, + ) + + sent = instance.calls[0] + assert sent["tools"] == [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "look up weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}, + }, + } + ] + assert sent["tool_choice"] == "required" + + +@pytest.mark.asyncio +async def test_stream_message_converts_anthropic_tools_and_tool_choice( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The streaming request path converts tools/tool_choice the same way.""" + backend, instance = make_backend(monkeypatch) + instance.response = FakeAsyncStream([]) + + _events = [ + event + async for event in backend.stream_message( + { + "model": "claude", + "messages": [], + "tools": [{"name": "t", "input_schema": {"type": "object"}}], + "tool_choice": {"type": "tool", "name": "t"}, + }, + {}, + ) + ] + + sent = instance.calls[0] + assert sent["tools"] == [ + {"type": "function", "function": {"name": "t", "parameters": {"type": "object"}}} + ] + assert sent["tool_choice"] == {"type": "function", "function": {"name": "t"}} + + @pytest.mark.asyncio async def test_send_message_returns_error_response(monkeypatch: pytest.MonkeyPatch) -> None: backend, instance = make_backend(monkeypatch)