mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
def3d76e5a
commit
0d6866b91a
2 changed files with 117 additions and 4 deletions
|
|
@ -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]}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue