diff --git a/studio/backend/core/inference/anthropic_compat.py b/studio/backend/core/inference/anthropic_compat.py index bc792c3b99..263718c540 100644 --- a/studio/backend/core/inference/anthropic_compat.py +++ b/studio/backend/core/inference/anthropic_compat.py @@ -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", {}), }, } ) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index f8bf219846..e32d134628 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -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): diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index cde86998d2..1b4e7051b0 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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} diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 842429d5af..0825ef9337 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -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"))