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]:
|
||||
"""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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue