fix(backends/anyllm): convert Anthropic tools and tool_choice to OpenAI shape

Convert Anthropic tool requests for AnyLLM OpenAI-compatible backends.
This commit is contained in:
Abhay Singh 2026-08-12 06:45:47 +05:30 committed by GitHub
parent def3d76e5a
commit 0d6866b91a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 117 additions and 4 deletions

View file

@ -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]}"

View file

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