From 9d6f706ac36425821dd9bbcbb9583463b59104cd Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:14:05 +0100 Subject: [PATCH] Fix Claude client tools under server tool policy (#7518) * Fix Claude client tools under server tool policy * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Preserve Anthropic client tool routing * Match text editor schemas by version --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../core/inference/anthropic_compat.py | 136 ++++++++++++++- studio/backend/models/inference.py | 3 +- studio/backend/routes/inference.py | 29 +++- .../backend/tests/test_anthropic_messages.py | 155 ++++++++++++++++++ 4 files changed, 312 insertions(+), 11 deletions(-) diff --git a/studio/backend/core/inference/anthropic_compat.py b/studio/backend/core/inference/anthropic_compat.py index 34445cc58e..a32e372d73 100644 --- a/studio/backend/core/inference/anthropic_compat.py +++ b/studio/backend/core/inference/anthropic_compat.py @@ -172,6 +172,136 @@ def anthropic_messages_to_openai( return result +_ANTHROPIC_SCHEMA_CLIENT_TOOL_PARAMETERS = { + "bash": { + "type": "object", + "properties": { + "command": {"type": "string"}, + "restart": {"type": "boolean"}, + }, + "anyOf": [ + {"required": ["command"]}, + {"properties": {"restart": {"const": True}}, "required": ["restart"]}, + ], + }, + "text_editor": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": ["view", "str_replace", "create", "insert"], + }, + "path": {"type": "string"}, + "view_range": { + "type": "array", + "items": {"type": "integer"}, + "minItems": 2, + "maxItems": 2, + }, + "old_str": {"type": "string"}, + "new_str": {"type": "string"}, + "file_text": {"type": "string"}, + "insert_line": {"type": "integer"}, + "insert_text": {"type": "string"}, + }, + "required": ["command", "path"], + }, + "computer": { + "type": "object", + "properties": { + "action": {"type": "string"}, + "coordinate": { + "type": "array", + "items": {"type": "integer"}, + "minItems": 2, + "maxItems": 2, + }, + "text": {"type": "string"}, + "duration": {"type": "number"}, + "scroll_direction": {"type": "string"}, + "scroll_amount": {"type": "integer"}, + "start_coordinate": { + "type": "array", + "items": {"type": "integer"}, + "minItems": 2, + "maxItems": 2, + }, + "key": {"type": "string"}, + }, + "required": ["action"], + "additionalProperties": True, + }, + "memory": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": ["view", "create", "str_replace", "insert", "delete", "rename"], + }, + "path": {"type": "string"}, + "view_range": { + "type": "array", + "items": {"type": "integer"}, + "minItems": 2, + "maxItems": 2, + }, + "file_text": {"type": "string"}, + "old_str": {"type": "string"}, + "new_str": {"type": "string"}, + "insert_line": {"type": "integer"}, + "insert_text": {"type": "string"}, + "old_path": {"type": "string"}, + "new_path": {"type": "string"}, + }, + "required": ["command"], + }, +} + +_ANTHROPIC_SCHEMA_CLIENT_TOOL_DESCRIPTIONS = { + "bash": "Run a command in the caller-owned persistent bash session, or restart it.", + "text_editor": "View, create, or edit files in the caller-owned filesystem.", + "computer": "Interact with the caller-owned computer using an action and its parameters.", + "memory": "Store and retrieve files in the caller-owned persistent memory directory.", +} + + +def anthropic_schema_client_tool_kind(tool) -> Optional[str]: + """Return the kind of a schema-less Anthropic client tool, if recognized.""" + td = tool if isinstance(tool, dict) else tool.model_dump() + if td.get("input_schema") is not None: + return None + type_ = td.get("type") + if not isinstance(type_, str): + return None + kind, separator, version = type_.rpartition("_") + if ( + separator + and kind in _ANTHROPIC_SCHEMA_CLIENT_TOOL_PARAMETERS + and len(version) == 8 + and version.isdigit() + ): + return kind + return None + + +def _anthropic_schema_client_tool_parameters(td: dict, kind: str) -> dict: + parameters = _ANTHROPIC_SCHEMA_CLIENT_TOOL_PARAMETERS[kind] + if kind != "text_editor": + return parameters + + version = td["type"].rpartition("_")[2] + commands = list(parameters["properties"]["command"]["enum"]) + if version < "20250429": + commands.append("undo_edit") + return { + **parameters, + "properties": { + **parameters["properties"], + "command": {**parameters["properties"]["command"], "enum": commands}, + }, + } + + def anthropic_tools_to_openai(tools: list) -> list[dict]: """Convert Anthropic client tools to OpenAI function-tool format.""" result = [] @@ -179,6 +309,9 @@ def anthropic_tools_to_openai(tools: list) -> list[dict]: td = t if isinstance(t, dict) else t.model_dump() name = td.get("name") input_schema = td.get("input_schema") + schema_client_kind = anthropic_schema_client_tool_kind(td) + if schema_client_kind is not None: + input_schema = _anthropic_schema_client_tool_parameters(td, schema_client_kind) if not name or input_schema is None: continue result.append( @@ -186,7 +319,8 @@ def anthropic_tools_to_openai(tools: list) -> list[dict]: "type": "function", "function": { "name": name, - "description": td.get("description", ""), + "description": td.get("description") + or _ANTHROPIC_SCHEMA_CLIENT_TOOL_DESCRIPTIONS.get(schema_client_kind, ""), "parameters": input_schema, }, } diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index add3228a28..fe59bc3e78 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -2031,7 +2031,8 @@ class AnthropicMessage(BaseModel): class AnthropicTool(BaseModel): - # Client tools have input_schema; server tools may only have type/name. + # User-defined client tools have input_schema; Anthropic-schema client tools + # and server tools use type/name. type: Optional[str] = None name: Optional[str] = None description: Optional[str] = None diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 0b0d3110f1..4e89522434 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1786,6 +1786,7 @@ from models.inference import ( ) from core.inference.anthropic_compat import ( anthropic_messages_to_openai, + anthropic_schema_client_tool_kind, anthropic_tools_to_openai, anthropic_tool_choice_to_openai, openai_finish_to_anthropic_stop, @@ -13447,8 +13448,7 @@ 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: + if td.get("input_schema") is not None or anthropic_schema_client_tool_kind(td) is not None: continue # Anthropic dispatches server tools by `type`, not bare `name`; matching # name too would let a malformed client tool like `{"name": "python"}` @@ -13541,18 +13541,21 @@ def _validate_anthropic_client_tools(tools) -> None: # Reject malformed client tools before any model load, so an invalid request # never evicts the loaded model. AnthropicTool relaxed name/input_schema to # Optional for server tools, so the converter silently drops incomplete - # entries; surface them as 400 here. A `type` field marks a server-tool - # declaration (unrecognized server tools are no-ops); anything else without - # input_schema or name is malformed. + # entries; surface them as 400 here. Recognized Anthropic-schema client + # tools use type/name without input_schema; other type declarations are + # server tools (unrecognized server tools remain no-ops). for tool in 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") + schema_client_kind = anthropic_schema_client_tool_kind(td) 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): + if (schema is not None or schema_client_kind is not None) and ( + not isinstance(name, str) or not name + ): raise HTTPException( status_code = 400, detail = "Client tool is missing required field 'name'.", @@ -13693,9 +13696,13 @@ async def anthropic_messages( requested_studio_tools = _anthropic_requested_studio_tools(payload.tools) _has_client_tool = any( (t if isinstance(t, dict) else t.model_dump()).get("input_schema") is not None + or anthropic_schema_client_tool_kind(t) is not None for t in payload.tools or [] ) - if requested_studio_tools and _has_client_tool: + _explicit_server_tools = bool(requested_studio_tools) or ( + payload.enable_tools is True and _effective_enable_tools(payload) is not False + ) + if _explicit_server_tools and _has_client_tool: raise HTTPException( status_code = 400, detail = ( @@ -13718,7 +13725,11 @@ async def anthropic_messages( # post-switch); an image request can never take the server-tool path, so it is # excluded as in the server_tools gate below. off/full and an explicit # confirm_tool_calls=False opt-out always pass. - _enable_pre = _effective_enable_tools(payload) + # A process-wide ``--enable-tools`` policy is only a default for ordinary + # chat. It must not steal an explicit Anthropic client-tool catalog (Claude + # Code's Write/Edit/Bash tools) and turn it into Unsloth's local tool loop. + # An explicit per-request server-tool ask was rejected as mixed mode above. + _enable_pre = False if _has_client_tool else _effective_enable_tools(payload) _server_tools_requested_pre = ( _enable_pre or (_enable_pre is None and bool(requested_studio_tools)) ) and not _anthropic_request_has_image(payload) @@ -13847,7 +13858,7 @@ async def anthropic_messages( # 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) + _enable = False if _has_client_tool else _effective_enable_tools(payload) server_tools = ( (_enable or (_enable is None and bool(requested_studio_tools))) and llama_backend.supports_tools diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 296cb80911..9c6bf5f8aa 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -28,6 +28,7 @@ from models.inference import ( ) from core.inference.anthropic_compat import ( anthropic_messages_to_openai, + anthropic_schema_client_tool_kind, anthropic_tools_to_openai, build_anthropic_sse_event, AnthropicStreamEmitter, @@ -626,6 +627,41 @@ class TestAnthropicToolsToOpenAI: ] assert anthropic_tools_to_openai(tools) == [] + @pytest.mark.parametrize( + ("type_", "name", "kind"), + [ + ("bash_20250124", "bash", "bash"), + ("text_editor_20250728", "str_replace_based_edit_tool", "text_editor"), + ("computer_20251124", "computer", "computer"), + ("memory_20250818", "memory", "memory"), + ], + ) + def test_schema_client_tools_are_converted_to_openai_functions(self, type_, name, kind): + tool = {"type": type_, "name": name} + + [result] = anthropic_tools_to_openai([tool]) + + assert anthropic_schema_client_tool_kind(tool) == kind + assert result["function"]["name"] == name + assert result["function"]["parameters"]["type"] == "object" + + @pytest.mark.parametrize( + ("type_", "supports_undo"), + [ + ("text_editor_20241022", True), + ("text_editor_20250124", True), + ("text_editor_20250429", False), + ("text_editor_20250728", False), + ], + ) + def test_text_editor_commands_follow_tool_version(self, type_, supports_undo): + [result] = anthropic_tools_to_openai( + [{"type": type_, "name": "str_replace_based_edit_tool"}] + ) + + commands = result["function"]["parameters"]["properties"]["command"]["enum"] + assert ("undo_edit" in commands) is supports_undo + def test_server_tool_selection_merges_enabled_tools_extension(self): all_tools = [ {"type": "function", "function": {"name": "web_search"}}, @@ -1735,6 +1771,116 @@ class TestAnthropicMessagesToolRouting: assert exc.value.status_code == 400 assert "Mixing Anthropic server tools" in exc.value.detail + def test_explicit_server_loop_and_client_tools_rejected_with_400(self, monkeypatch): + _mock_backend(monkeypatch) + payload = _basic_payload( + enable_tools = True, + tools = [{"name": "Write", "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_explicit_server_loop_and_schema_client_tools_rejected_with_400(self, monkeypatch): + _mock_backend(monkeypatch) + payload = _basic_payload( + enable_tools = True, + tools = [{"type": "bash_20250124", "name": "bash"}], + ) + + 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_process_tool_policy_does_not_steal_schema_client_tools(self, monkeypatch): + import routes.inference as inf_mod + from fastapi.responses import JSONResponse + + backend = _mock_backend(monkeypatch) + captured = {} + + async def _passthrough(*args, **kwargs): + captured["tools"] = args[2] + return JSONResponse( + { + "id": "msg_test", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "ok"}], + "model": "test-model", + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + ) + + monkeypatch.setattr(inf_mod, "_anthropic_passthrough_non_streaming", _passthrough) + set_tool_policy(True) + payload = _basic_payload(tools = [{"type": "bash_20250124", "name": "bash"}]) + + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + + assert backend.calls == [] + assert captured["tools"][0]["function"]["name"] == "bash" + + @pytest.mark.parametrize("permission_mode", [None, "ask"]) + @pytest.mark.parametrize( + ("tool_policy", "enable_tools"), + [(True, None), (False, True)], + ) + def test_process_tool_policy_does_not_steal_client_tools( + self, monkeypatch, permission_mode, tool_policy, enable_tools + ): + """A server-wide tool default must not replace Claude Code's own tools.""" + import routes.inference as inf_mod + from fastapi.responses import JSONResponse + + backend = _mock_backend(monkeypatch) + captured = {} + + async def _passthrough(*args, **kwargs): + captured["tools"] = args[2] + return JSONResponse( + { + "id": "msg_test", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "ok"}], + "model": "test-model", + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + ) + + monkeypatch.setattr(inf_mod, "_anthropic_passthrough_non_streaming", _passthrough) + set_tool_policy(tool_policy) + fields = { + "tools": [ + { + "name": "Write", + "description": "Write a file", + "input_schema": { + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + } + ], + } + if enable_tools is not None: + fields["enable_tools"] = enable_tools + if permission_mode is not None: + fields["permission_mode"] = permission_mode + payload = _basic_payload(**fields) + + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + + assert backend.calls == [] + assert captured["tools"][0]["function"]["name"] == "Write" + 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. a custom "web_search") must still trigger the mixed-mode 400; @@ -1780,6 +1926,15 @@ class TestAnthropicMessagesToolRouting: assert exc.value.status_code == 400 assert "name" in exc.value.detail + def test_schema_client_tool_missing_name_rejected_with_400(self, monkeypatch): + _mock_backend(monkeypatch) + payload = _basic_payload(tools = [{"type": "bash_20250124"}]) + + 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