diff --git a/studio/backend/core/inference/anthropic_compat.py b/studio/backend/core/inference/anthropic_compat.py index 263718c540..bc792c3b99 100644 --- a/studio/backend/core/inference/anthropic_compat.py +++ b/studio/backend/core/inference/anthropic_compat.py @@ -152,17 +152,21 @@ def anthropic_messages_to_openai( def anthropic_tools_to_openai(tools: list) -> list[dict]: - """Convert Anthropic tool definitions to OpenAI function-tool format.""" + """Convert Anthropic client tools 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": td["name"], + "name": name, "description": td.get("description", ""), - "parameters": td.get("input_schema", {}), + "parameters": input_schema, }, } ) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index e32d134628..f8bf219846 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -1251,9 +1251,12 @@ class AnthropicMessage(BaseModel): class AnthropicTool(BaseModel): - name: str + # Client tools have input_schema; server tools may only have type/name. + type: Optional[str] = None + name: Optional[str] = None description: Optional[str] = None - input_schema: dict + input_schema: Optional[dict] = None + model_config = {"extra": "allow"} class AnthropicMessagesRequest(BaseModel): diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 1b4e7051b0..cde86998d2 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -67,7 +67,11 @@ 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)) + and ( + "cancel scope" in str(exc_value) + or "GeneratorExit" in str(exc_value) + or "no running event loop" in str(exc_value) + ) ): return prior_hook(unraisable) @@ -4215,6 +4219,49 @@ 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: @@ -4338,21 +4385,74 @@ 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 = ( - _effective_enable_tools(payload) + (_enable or (_enable is None and bool(requested_studio_tools))) and llama_backend.supports_tools and not _has_image ) client_tools = ( not server_tools - and payload.tools - and len(payload.tools) > 0 + and len(openai_client_tools) > 0 and llama_backend.supports_tools ) # ── Client-side pass-through path ───────────────────────── if client_tools: - openai_tools = anthropic_tools_to_openai(payload.tools) + openai_tools = openai_client_tools if payload.stream: return await _anthropic_passthrough_stream( @@ -4395,12 +4495,11 @@ async def anthropic_messages( if server_tools: from core.inference.tools import ALL_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 + openai_tools = _select_anthropic_server_tools( + ALL_TOOLS, + requested_studio_tools, + payload.enabled_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 0825ef9337..842429d5af 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -34,10 +34,18 @@ from core.inference.anthropic_compat import ( AnthropicStreamEmitter, AnthropicPassthroughEmitter, ) -from routes.inference import _normalize_anthropic_openai_images +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 fastapi import HTTPException +import asyncio import base64 as _b64 from io import BytesIO as _BytesIO +from types import SimpleNamespace # ===================================================================== @@ -78,6 +86,17 @@ 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, @@ -424,6 +443,31 @@ 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"} @@ -1011,3 +1055,245 @@ 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"))