Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Roland Tannous
53b0c72b7f
Revert "Studio: Claude Code Anthropic API tool compatibility (#5390)"
This reverts commit 966d3cda47.
2026-05-21 17:31:35 +04:00
4 changed files with 17 additions and 409 deletions

View file

@ -152,21 +152,17 @@ def anthropic_messages_to_openai(
def anthropic_tools_to_openai(tools: list) -> list[dict]:
"""Convert Anthropic client tools to OpenAI function-tool format."""
"""Convert Anthropic tool definitions to OpenAI function-tool format."""
result = []
for t in tools:
td = t if isinstance(t, dict) else t.model_dump()
name = td.get("name")
input_schema = td.get("input_schema")
if not name or input_schema is None:
continue
result.append(
{
"type": "function",
"function": {
"name": name,
"name": td["name"],
"description": td.get("description", ""),
"parameters": input_schema,
"parameters": td.get("input_schema", {}),
},
}
)

View file

@ -1251,12 +1251,9 @@ class AnthropicMessage(BaseModel):
class AnthropicTool(BaseModel):
# Client tools have input_schema; server tools may only have type/name.
type: Optional[str] = None
name: Optional[str] = None
name: str
description: Optional[str] = None
input_schema: Optional[dict] = None
model_config = {"extra": "allow"}
input_schema: dict
class AnthropicMessagesRequest(BaseModel):

View file

@ -67,11 +67,7 @@ def _install_httpcore_asyncgen_silencer() -> None:
if (
isinstance(exc_value, RuntimeError)
and "HTTP11ConnectionByteStream" in obj_repr
and (
"cancel scope" in str(exc_value)
or "GeneratorExit" in str(exc_value)
or "no running event loop" in str(exc_value)
)
and ("cancel scope" in str(exc_value) or "GeneratorExit" in str(exc_value))
):
return
prior_hook(unraisable)
@ -4219,49 +4215,6 @@ async def openai_responses(
# =====================================================================
_STUDIO_ANTHROPIC_TOOL_ALIASES = {
"web_search": "web_search",
"web_search_20250305": "web_search",
"web_fetch": "web_search",
"web_fetch_20250910": "web_search",
"web_fetch_20260209": "web_search",
"python": "python",
"terminal": "terminal",
}
def _anthropic_requested_studio_tools(tools: Optional[list]) -> set[str]:
requested: set[str] = set()
for tool in tools or []:
td = tool if isinstance(tool, dict) else tool.model_dump()
# Client tools always carry input_schema; server tools never do.
if td.get("input_schema") is not None:
continue
# Anthropic dispatches server tools by `type` (not by bare `name`);
# matching name too would let a malformed client tool like
# `{"name": "python"}` silently flip into server-execution mode.
type_ = td.get("type")
if isinstance(type_, str) and type_ in _STUDIO_ANTHROPIC_TOOL_ALIASES:
requested.add(_STUDIO_ANTHROPIC_TOOL_ALIASES[type_])
return requested
def _select_anthropic_server_tools(
all_tools: list[dict],
requested_studio_tools: set[str],
enabled_tools: Optional[list[str]],
) -> list[dict]:
"""Select Studio tools requested through Anthropic tools and extensions."""
if not requested_studio_tools and enabled_tools is None:
return all_tools
selected_names = set(requested_studio_tools)
if enabled_tools is not None:
selected_names.update(enabled_tools)
return [tool for tool in all_tools if tool["function"]["name"] in selected_names]
def _normalize_anthropic_openai_images(
openai_messages: list[dict], is_vision: bool
) -> bool:
@ -4385,74 +4338,21 @@ async def anthropic_messages(
# 3. neither → plain chat
# Server-side agentic loop doesn't support multimodal input — matches
# the `not image_b64` gate in /v1/chat/completions.
requested_studio_tools = _anthropic_requested_studio_tools(payload.tools)
# Reject malformed client tools at the boundary. AnthropicTool was
# relaxed to Optional[name]/Optional[input_schema] for server tools,
# so the converter silently drops incomplete entries — surface them
# as 400. A `type` field marks a server-tool declaration per spec
# (unrecognized server tools are accepted as no-ops); anything else
# without input_schema or name is malformed and must not be allowed
# to silently flip execution mode or disable tool calling.
for tool in payload.tools or []:
td = tool if isinstance(tool, dict) else tool.model_dump()
name, type_, schema = td.get("name"), td.get("type"), td.get("input_schema")
if schema is None and not isinstance(type_, str):
raise HTTPException(
status_code = 400,
detail = f"Tool {name!r} is missing required field 'input_schema'.",
)
if schema is not None and (not isinstance(name, str) or not name):
raise HTTPException(
status_code = 400,
detail = "Client tool is missing required field 'name'.",
)
# Detect client tools from the raw payload (presence of input_schema)
# so the mixed-mode check below isn't fooled by a name collision with
# a server-tool alias that the post-filter would silently drop.
_has_client_tool = any(
(t if isinstance(t, dict) else t.model_dump()).get("input_schema") is not None
for t in payload.tools or []
)
# The server-tool agentic loop executes tools in-process and cannot
# relay unknown client functions back to the caller, so mixed requests
# would silently drop the client tools. Reject explicitly instead.
if requested_studio_tools and _has_client_tool:
raise HTTPException(
status_code = 400,
detail = (
"Mixing Anthropic server tools (e.g. web_search_20250305) "
"with custom client tools in a single request is not "
"supported. Send them in separate requests."
),
)
openai_client_tools = [
tool
for tool in anthropic_tools_to_openai(payload.tools or [])
if tool.get("function", {}).get("name") not in requested_studio_tools
]
# An Anthropic server-tool declaration implies server-tool mode, but
# only when tools aren't explicitly disabled (CLI --disable-tools or
# per-request enable_tools=false). Explicit False always wins.
_enable = _effective_enable_tools(payload)
server_tools = (
(_enable or (_enable is None and bool(requested_studio_tools)))
_effective_enable_tools(payload)
and llama_backend.supports_tools
and not _has_image
)
client_tools = (
not server_tools
and len(openai_client_tools) > 0
and payload.tools
and len(payload.tools) > 0
and llama_backend.supports_tools
)
# ── Client-side pass-through path ─────────────────────────
if client_tools:
openai_tools = openai_client_tools
openai_tools = anthropic_tools_to_openai(payload.tools)
if payload.stream:
return await _anthropic_passthrough_stream(
@ -4495,11 +4395,12 @@ async def anthropic_messages(
if server_tools:
from core.inference.tools import ALL_TOOLS
openai_tools = _select_anthropic_server_tools(
ALL_TOOLS,
requested_studio_tools,
payload.enabled_tools,
)
if payload.enabled_tools is not None:
openai_tools = [
t for t in ALL_TOOLS if t["function"]["name"] in payload.enabled_tools
]
else:
openai_tools = ALL_TOOLS
# Build tool-use system prompt nudge (same logic as /chat/completions)
_tool_names = {t["function"]["name"] for t in openai_tools}

View file

@ -34,18 +34,10 @@ from core.inference.anthropic_compat import (
AnthropicStreamEmitter,
AnthropicPassthroughEmitter,
)
from routes.inference import (
_normalize_anthropic_openai_images,
_select_anthropic_server_tools,
_anthropic_requested_studio_tools,
anthropic_messages,
)
from state.tool_policy import reset_tool_policy, set_tool_policy
from routes.inference import _normalize_anthropic_openai_images
from fastapi import HTTPException
import asyncio
import base64 as _b64
from io import BytesIO as _BytesIO
from types import SimpleNamespace
# =====================================================================
@ -86,17 +78,6 @@ class TestAnthropicModels:
assert len(req.tools) == 1
assert req.tools[0].name == "web_search"
def test_server_tool_field_parses(self):
req = AnthropicMessagesRequest(
max_tokens = 100,
messages = [{"role": "user", "content": "Hi"}],
tools = [{"type": "web_fetch_20250910", "name": "web_fetch"}],
)
assert len(req.tools) == 1
assert req.tools[0].type == "web_fetch_20250910"
assert req.tools[0].name == "web_fetch"
assert req.tools[0].input_schema is None
def test_extra_fields_accepted(self):
req = AnthropicMessagesRequest(
max_tokens = 100,
@ -443,31 +424,6 @@ class TestAnthropicToolsToOpenAI:
def test_empty_list(self):
assert anthropic_tools_to_openai([]) == []
def test_server_tools_are_not_converted_to_openai_functions(self):
tools = [
{"type": "web_fetch_20250910", "name": "web_fetch"},
{"type": "web_search_20250305", "name": "web_search"},
]
assert anthropic_tools_to_openai(tools) == []
def test_server_tool_selection_merges_enabled_tools_extension(self):
all_tools = [
{"type": "function", "function": {"name": "web_search"}},
{"type": "function", "function": {"name": "python"}},
{"type": "function", "function": {"name": "terminal"}},
]
result = _select_anthropic_server_tools(
all_tools,
requested_studio_tools = {"web_search"},
enabled_tools = ["python"],
)
assert [tool["function"]["name"] for tool in result] == [
"web_search",
"python",
]
def test_pydantic_model_input(self):
tool = AnthropicTool(
name = "test", description = "desc", input_schema = {"type": "object"}
@ -1055,245 +1011,3 @@ class TestNormalizeAnthropicOpenAIImages:
with pytest.raises(HTTPException) as exc:
_normalize_anthropic_openai_images(msgs, is_vision = True)
assert exc.value.status_code == 400
# =====================================================================
# Studio-tool alias detection (/v1/messages tool routing)
# =====================================================================
class TestAnthropicRequestedStudioTools:
def test_recognizes_server_tool_by_type(self):
tools = [{"type": "web_search_20250305", "name": "web_search"}]
assert _anthropic_requested_studio_tools(tools) == {"web_search"}
def test_bare_name_without_type_is_not_treated_as_server_tool(self):
# Anthropic dispatches server tools by `type`; bare-name matching
# would let a malformed client tool (e.g. user forgot input_schema)
# silently flip the request into server-execution mode.
tools = [{"name": "python"}]
assert _anthropic_requested_studio_tools(tools) == set()
def test_client_tool_named_python_is_not_misclassified(self):
# input_schema is the client-tool discriminator; presence of it
# must prevent the name from being treated as a Studio alias.
tools = [
{
"name": "python",
"description": "user's own python",
"input_schema": {"type": "object"},
}
]
assert _anthropic_requested_studio_tools(tools) == set()
def test_mixed_request_only_extracts_server_tools(self):
tools = [
{"type": "web_search_20250305", "name": "web_search"},
{"name": "custom_tool", "input_schema": {"type": "object"}},
]
assert _anthropic_requested_studio_tools(tools) == {"web_search"}
def test_pydantic_model_input(self):
tools = [
AnthropicTool(type = "web_fetch_20250910", name = "web_fetch"),
AnthropicTool(name = "x", input_schema = {"type": "object"}),
]
assert _anthropic_requested_studio_tools(tools) == {"web_search"}
def test_empty_and_none(self):
assert _anthropic_requested_studio_tools(None) == set()
assert _anthropic_requested_studio_tools([]) == set()
# =====================================================================
# Route-level tool routing (/v1/messages)
# =====================================================================
class _PlainPathCalled(Exception):
pass
class _ToolPathCalled(Exception):
pass
def _mock_backend(monkeypatch, **overrides):
"""Install a minimal stub backend on routes.inference.
Generation methods raise sentinel exceptions so the caller can assert
which path the route entered.
"""
import routes.inference as inf_mod
def _gen_plain(**kwargs):
raise _PlainPathCalled()
def _gen_tools(**kwargs):
raise _ToolPathCalled()
backend = SimpleNamespace(
is_loaded = True,
is_vision = False,
supports_tools = True,
model_identifier = "test-model",
generate_chat_completion = _gen_plain,
generate_chat_completion_with_tools = _gen_tools,
)
backend.__dict__.update(overrides)
monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: backend)
return backend
def _drive(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _basic_payload(**fields) -> AnthropicMessagesRequest:
base = {
"max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}],
}
base.update(fields)
return AnthropicMessagesRequest(**base)
@pytest.fixture(autouse = True)
def _reset_policy():
reset_tool_policy()
yield
reset_tool_policy()
class TestAnthropicMessagesToolRouting:
def test_mixed_server_and_client_tools_rejected_with_400(self, monkeypatch):
_mock_backend(monkeypatch)
payload = _basic_payload(
tools = [
{"type": "web_search_20250305", "name": "web_search"},
{"name": "custom", "input_schema": {"type": "object"}},
],
)
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert "Mixing Anthropic server tools" in exc.value.detail
def test_mixed_rejected_when_client_tool_name_collides_with_server_alias(
self, monkeypatch
):
# Regression: a client tool sharing a name with a mapped server
# tool (e.g. user defines their own "web_search") must still
# trigger the mixed-mode 400 — the post-name filter would
# otherwise drop the client tool and silently route to server-only.
_mock_backend(monkeypatch)
payload = _basic_payload(
tools = [
{"type": "web_search_20250305", "name": "web_search"},
{"name": "web_search", "input_schema": {"type": "object"}},
],
)
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert "Mixing Anthropic server tools" in exc.value.detail
def test_client_tool_missing_input_schema_rejected_with_400(self, monkeypatch):
_mock_backend(monkeypatch)
payload = _basic_payload(
tools = [{"name": "my_tool", "description": "oops, schema typo"}],
)
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert "input_schema" in exc.value.detail
def test_client_tool_missing_name_rejected_with_400(self, monkeypatch):
# Regression: AnthropicTool.name was relaxed to Optional for server
# tools, so a client-tool payload that has input_schema but omits
# `name` (e.g. typo) now parses successfully but would be silently
# dropped by anthropic_tools_to_openai, leaving the request with
# tool calling disabled. Reject at the boundary instead.
_mock_backend(monkeypatch)
payload = _basic_payload(
tools = [{"input_schema": {"type": "object"}}],
)
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert "name" in exc.value.detail
def test_client_tool_empty_name_rejected_with_400(self, monkeypatch):
# Same silent-disable class as missing-name: `name: ""` passes the
# isinstance check but is dropped by anthropic_tools_to_openai's
# `if not name` guard. Reject at the boundary so the typo surfaces.
_mock_backend(monkeypatch)
payload = _basic_payload(
tools = [{"name": "", "input_schema": {"type": "object"}}],
)
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert "name" in exc.value.detail
def test_alias_named_client_tool_without_schema_rejected_with_400(
self, monkeypatch
):
# Regression: a typo'd client tool whose name happens to collide
# with a Studio alias (e.g. user meant a custom "python" tool but
# forgot input_schema) must surface a 400, not silently switch
# the request into Studio's built-in python execution.
_mock_backend(monkeypatch)
payload = _basic_payload(tools = [{"name": "python"}])
with pytest.raises(HTTPException) as exc:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert exc.value.status_code == 400
assert "input_schema" in exc.value.detail
def test_unrecognized_server_tool_accepted_as_noop(self, monkeypatch):
_mock_backend(monkeypatch)
payload = _basic_payload(
tools = [{"type": "code_execution_20250825", "name": "code_execution"}],
)
with pytest.raises(_PlainPathCalled):
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
def test_disable_tools_policy_overrides_server_tool_alias(self, monkeypatch):
# CLI `unsloth run --disable-tools` sets policy=False. A request
# carrying a Studio server-tool alias must NOT enter the agentic
# loop in that configuration.
_mock_backend(monkeypatch)
set_tool_policy(False)
payload = _basic_payload(
tools = [{"type": "web_search_20250305", "name": "web_search"}],
)
with pytest.raises(_PlainPathCalled):
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
def test_server_tool_alias_enters_tool_path_when_policy_unset(self, monkeypatch):
# Mirror of the previous test for the default (None) policy.
_mock_backend(monkeypatch)
payload = _basic_payload(
tools = [{"type": "web_search_20250305", "name": "web_search"}],
)
with pytest.raises(_ToolPathCalled):
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
def test_per_request_enable_tools_false_blocks_server_tool_alias(self, monkeypatch):
_mock_backend(monkeypatch)
payload = _basic_payload(
enable_tools = False,
tools = [{"type": "web_search_20250305", "name": "web_search"}],
)
with pytest.raises(_PlainPathCalled):
_drive(anthropic_messages(payload, request = None, current_subject = "t"))