Studio: Claude Code Anthropic API tool compatibility (#5390)
* fix: Claude Code Anthropic API tool compatibility * fix: merge Anthropic server tool selections * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix anthropic /v1/messages server-tool alias misrout * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: harden Anthropic /v1/messages tool validation * fix: dispatch Anthropic server tools by only * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: reject Anthropic client tools missing 'name' at boundary AnthropicTool.name was relaxed to Optional[str] to accommodate server-tool declarations. A client tool with input_schema but no name now parses but is silently dropped by anthropic_tools_to_openai, leaving tool calling disabled. Surface as 400 instead. * fix: reject Anthropic client tools with empty 'name' isinstance(name, str) accepts an empty string, but anthropic_tools_to_openai drops entries via 'if not name', producing the same silent-disable fallthrough the boundary check is meant to prevent. Tighten to also reject empty name. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
This commit is contained in:
parent
b180ae7dd6
commit
966d3cda47
4 changed files with 409 additions and 17 deletions
|
|
@ -152,17 +152,21 @@ def anthropic_messages_to_openai(
|
||||||
|
|
||||||
|
|
||||||
def anthropic_tools_to_openai(tools: list) -> list[dict]:
|
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 = []
|
result = []
|
||||||
for t in tools:
|
for t in tools:
|
||||||
td = t if isinstance(t, dict) else t.model_dump()
|
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(
|
result.append(
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": td["name"],
|
"name": name,
|
||||||
"description": td.get("description", ""),
|
"description": td.get("description", ""),
|
||||||
"parameters": td.get("input_schema", {}),
|
"parameters": input_schema,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1251,9 +1251,12 @@ class AnthropicMessage(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class AnthropicTool(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
|
description: Optional[str] = None
|
||||||
input_schema: dict
|
input_schema: Optional[dict] = None
|
||||||
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessagesRequest(BaseModel):
|
class AnthropicMessagesRequest(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,11 @@ def _install_httpcore_asyncgen_silencer() -> None:
|
||||||
if (
|
if (
|
||||||
isinstance(exc_value, RuntimeError)
|
isinstance(exc_value, RuntimeError)
|
||||||
and "HTTP11ConnectionByteStream" in obj_repr
|
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
|
return
|
||||||
prior_hook(unraisable)
|
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(
|
def _normalize_anthropic_openai_images(
|
||||||
openai_messages: list[dict], is_vision: bool
|
openai_messages: list[dict], is_vision: bool
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
@ -4338,21 +4385,74 @@ async def anthropic_messages(
|
||||||
# 3. neither → plain chat
|
# 3. neither → plain chat
|
||||||
# Server-side agentic loop doesn't support multimodal input — matches
|
# Server-side agentic loop doesn't support multimodal input — matches
|
||||||
# the `not image_b64` gate in /v1/chat/completions.
|
# 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 = (
|
server_tools = (
|
||||||
_effective_enable_tools(payload)
|
(_enable or (_enable is None and bool(requested_studio_tools)))
|
||||||
and llama_backend.supports_tools
|
and llama_backend.supports_tools
|
||||||
and not _has_image
|
and not _has_image
|
||||||
)
|
)
|
||||||
client_tools = (
|
client_tools = (
|
||||||
not server_tools
|
not server_tools
|
||||||
and payload.tools
|
and len(openai_client_tools) > 0
|
||||||
and len(payload.tools) > 0
|
|
||||||
and llama_backend.supports_tools
|
and llama_backend.supports_tools
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Client-side pass-through path ─────────────────────────
|
# ── Client-side pass-through path ─────────────────────────
|
||||||
if client_tools:
|
if client_tools:
|
||||||
openai_tools = anthropic_tools_to_openai(payload.tools)
|
openai_tools = openai_client_tools
|
||||||
|
|
||||||
if payload.stream:
|
if payload.stream:
|
||||||
return await _anthropic_passthrough_stream(
|
return await _anthropic_passthrough_stream(
|
||||||
|
|
@ -4395,12 +4495,11 @@ async def anthropic_messages(
|
||||||
if server_tools:
|
if server_tools:
|
||||||
from core.inference.tools import ALL_TOOLS
|
from core.inference.tools import ALL_TOOLS
|
||||||
|
|
||||||
if payload.enabled_tools is not None:
|
openai_tools = _select_anthropic_server_tools(
|
||||||
openai_tools = [
|
ALL_TOOLS,
|
||||||
t for t in ALL_TOOLS if t["function"]["name"] in payload.enabled_tools
|
requested_studio_tools,
|
||||||
]
|
payload.enabled_tools,
|
||||||
else:
|
)
|
||||||
openai_tools = ALL_TOOLS
|
|
||||||
|
|
||||||
# Build tool-use system prompt nudge (same logic as /chat/completions)
|
# Build tool-use system prompt nudge (same logic as /chat/completions)
|
||||||
_tool_names = {t["function"]["name"] for t in openai_tools}
|
_tool_names = {t["function"]["name"] for t in openai_tools}
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,18 @@ from core.inference.anthropic_compat import (
|
||||||
AnthropicStreamEmitter,
|
AnthropicStreamEmitter,
|
||||||
AnthropicPassthroughEmitter,
|
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
|
from fastapi import HTTPException
|
||||||
|
import asyncio
|
||||||
import base64 as _b64
|
import base64 as _b64
|
||||||
from io import BytesIO as _BytesIO
|
from io import BytesIO as _BytesIO
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
@ -78,6 +86,17 @@ class TestAnthropicModels:
|
||||||
assert len(req.tools) == 1
|
assert len(req.tools) == 1
|
||||||
assert req.tools[0].name == "web_search"
|
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):
|
def test_extra_fields_accepted(self):
|
||||||
req = AnthropicMessagesRequest(
|
req = AnthropicMessagesRequest(
|
||||||
max_tokens = 100,
|
max_tokens = 100,
|
||||||
|
|
@ -424,6 +443,31 @@ class TestAnthropicToolsToOpenAI:
|
||||||
def test_empty_list(self):
|
def test_empty_list(self):
|
||||||
assert anthropic_tools_to_openai([]) == []
|
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):
|
def test_pydantic_model_input(self):
|
||||||
tool = AnthropicTool(
|
tool = AnthropicTool(
|
||||||
name = "test", description = "desc", input_schema = {"type": "object"}
|
name = "test", description = "desc", input_schema = {"type": "object"}
|
||||||
|
|
@ -1011,3 +1055,245 @@ class TestNormalizeAnthropicOpenAIImages:
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
_normalize_anthropic_openai_images(msgs, is_vision = True)
|
_normalize_anthropic_openai_images(msgs, is_vision = True)
|
||||||
assert exc.value.status_code == 400
|
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"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue