From 1fa41c9fde319c81f3fe72503a2776b61acf2cb0 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 6 Oct 2026 23:35:23 +0800 Subject: [PATCH] core/agent: run a hosted search as the surface's own tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek's Anthropic compat runs a hosted ``web_search`` itself and answers with the blocks that search produced — ``thinking``, ``server_tool_use`` (the query) and ``web_search_tool_result`` — so the content union that knew only text and tool_use could not decode a live search turn. It carries them now, ``tools`` accepts the opaque server-tool dicts the surface declares, and ``web_search_tool_result.content`` stays loose on purpose: its ``web_search_result`` entries hold an ``encrypted_content`` the API demands back unchanged (a trimmed result is answered with 422 "missing field ``encrypted_content``"). ``provider_tools`` reached only the Responses surface, and the Anthropic one wants its own definition of the same intent: a bare ``web_search`` becomes ``{type="web_search_20250305", name="web_search"}`` (the server tool Claude declares and DeepSeek's compat reads, keeping a ``max_uses`` cap or any other key), merged after the local function tools. Such a tool is the server's: it streams ``input_json_delta`` fragments like a call, and reading them as one asked the registry for a tool the server had already run. Block types are now tracked, so only a ``tool_use`` block becomes a tool call; the search blocks and ``thinking`` are skipped by the response mapping (the answer the model wrote already used them) and never executed. --- src/plyngent/agent/loop.py | 1 + src/plyngent/agent/messages_bridge.py | 54 +++++++++-- src/plyngent/agent/messages_dispatch.py | 47 ++++++++-- src/plyngent/lmproto/anthropic/model.py | 50 +++++++++- tests/test_agent/test_messages_bridge.py | 68 ++++++++++++++ tests/test_agent/test_messages_dispatch.py | 104 +++++++++++++++++++++ tests/test_lmproto/test_anthropic.py | 70 ++++++++++++++ 7 files changed, 376 insertions(+), 18 deletions(-) diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index 4e98dc4..655b190 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -221,6 +221,7 @@ async def _dispatch_chat_completions( return await dispatch_messages( cast("AnthropicClient", client), param, + provider_tools=provider_tools, stream=stream, ) msg = f"client kind {kind!r} is not supported by the agent loop" diff --git a/src/plyngent/agent/messages_bridge.py b/src/plyngent/agent/messages_bridge.py index ccbda74..06bf457 100644 --- a/src/plyngent/agent/messages_bridge.py +++ b/src/plyngent/agent/messages_bridge.py @@ -18,6 +18,7 @@ from plyngent.lmproto.anthropic.model import ( AnthropicMessageResponse, AnthropicMessagesParam, AnthropicResponseText, + AnthropicResponseToolUse, AnthropicTextContent, AnthropicThinkingConfig, AnthropicToolChoice, @@ -69,11 +70,11 @@ def _encode_tool_arguments(obj: dict[str, Any]) -> str: def tool_items_to_anthropic_tools( tools: Sequence[AnyToolItem] | None, -) -> list[AnthropicToolDefinition]: +) -> list[AnthropicToolDefinition | dict[str, Any]]: """Map chat ``ToolFunctionItem`` list to Anthropic tool definitions.""" if not tools: return [] - result: list[AnthropicToolDefinition] = [] + result: list[AnthropicToolDefinition | dict[str, Any]] = [] for item in tools: if not isinstance(item, ToolFunctionItem): continue @@ -88,6 +89,35 @@ def tool_items_to_anthropic_tools( return result +# The surface-neutral hosted-tool intent (``provider_tools`` default) as the +# Anthropic server tool it means; DeepSeek's compat and Claude both define the +# search tool this way, and both ignore an OpenAI-shaped ``web_search`` entry. +_ANTHROPIC_WEB_SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20250305", "name": "web_search"} + + +def provider_tools_to_anthropic_tools( + provider_tools: Sequence[dict[str, Any]] | None, +) -> list[dict[str, Any]]: + """Map hosted-tool dicts to Anthropic server tools (``[]`` when none). + + Entries arrive in the OpenAI Responses shape the config defaults to, where a + bare ``web_search`` states the intent only; the Anthropic surface wants its + own definition, so that entry becomes + ``{"type": "web_search_20250305", "name": "web_search"}`` — the server tool + Claude declares and DeepSeek's compat reads — keeping any other key (a + ``max_uses`` cap, say). Anything else passes through as it is. + """ + result: list[dict[str, Any]] = [] + for item in provider_tools or (): + entry = dict(item) + if entry.get("type") == "web_search": + extra = {key: value for key, value in entry.items() if key not in {"type", "name"}} + entry = {**_ANTHROPIC_WEB_SEARCH_TOOL, **extra} + if entry.get("type"): + result.append(entry) + return result + + def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice | Unset: if param.tool_choice is UNSET: return UNSET @@ -190,7 +220,12 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> AssistantChatMessage: - """Map a completed Anthropic message to agent ``AssistantChatMessage``.""" + """Map a completed Anthropic message to agent ``AssistantChatMessage``. + + Server-side blocks (``server_tool_use`` / ``web_search_tool_result``) and + ``thinking`` are not part of the chat-shaped message: a search the model ran + through its backend already shows up in the text it answered with. + """ text_parts: list[str] = [] tool_calls: list[AnyAssistantToolCall] = [] for block in response.content: @@ -198,7 +233,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis if block.text: text_parts.append(block.text) continue - # AnthropicResponseToolUse (remaining content arm) + if not isinstance(block, AnthropicResponseToolUse): + continue tool_calls.append( AssistantFunctionToolCall( id=block.id, @@ -338,14 +374,20 @@ def chat_param_to_anthropic_param( param: ChatCompletionsParam, *, thinking_budget_tokens: int = 0, + provider_tools: Sequence[dict[str, Any]] | None = None, ) -> AnthropicMessagesParam: """Build :class:`AnthropicMessagesParam` from a chat completions param. *thinking_budget_tokens* is the configured explicit ``thinking`` budget - (0 = derive it from the param's ``reasoning_effort``). + (0 = derive it from the param's ``reasoning_effort``); *provider_tools* are + hosted tools (web_search, …) as opaque dicts, merged after local function + tools and never executed by the registry. """ system, messages = chat_messages_to_anthropic(param.messages) - tools = tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None) + tools: list[AnthropicToolDefinition | dict[str, Any]] = list( + tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None) + ) + tools.extend(provider_tools_to_anthropic_tools(provider_tools)) max_tokens, thinking = _thinking_and_max_tokens(param, thinking_budget_tokens=thinking_budget_tokens) kwargs: dict[str, Any] = { diff --git a/src/plyngent/agent/messages_dispatch.py b/src/plyngent/agent/messages_dispatch.py index da0ca94..c373188 100644 --- a/src/plyngent/agent/messages_dispatch.py +++ b/src/plyngent/agent/messages_dispatch.py @@ -33,7 +33,7 @@ from plyngent.lmproto.anthropic.model import ( ) if TYPE_CHECKING: - from collections.abc import AsyncIterator + from collections.abc import AsyncIterator, Sequence from plyngent.lmproto.anthropic.client import AnthropicClient from plyngent.lmproto.anthropic.model import AnthropicStreamEvent @@ -92,6 +92,7 @@ class _StreamState: model: str tool_blocks: dict[int, _ToolBlockState] + block_types: dict[int, str] next_tool_index: int stop_reason: str | None last_usage: dict[str, Any] | None @@ -101,6 +102,7 @@ class _StreamState: def __init__(self, model: str) -> None: self.model = model self.tool_blocks = {} + self.block_types = {} self.next_tool_index = 0 self.stop_reason = None self.last_usage = None @@ -126,11 +128,19 @@ class _StreamState: return self.start_tool(block_index=block_index, call_id=f"toolu_{block_index}", name="") -def _handle_tool_use_start( +def _handle_block_start( event: AnthropicContentBlockStart, state: _StreamState, ) -> list[ChatCompletionChunk]: + """Record the block's type; only ``tool_use`` becomes a local tool call. + + ``server_tool_use`` (a search the model's backend runs), its + ``web_search_tool_result``, and ``thinking`` stream here too — recording + their type keeps the argument fragments that follow from being mistaken for + a local call's arguments (see ``_chunks_for_content_delta``). + """ block = event.content_block + state.block_types[event.index] = block.type if block.type != "tool_use": return [] call_id = block.id or f"toolu_{event.index}" @@ -170,7 +180,7 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[ return [] if isinstance(event, AnthropicContentBlockStart): - return _handle_tool_use_start(event, state) + return _handle_block_start(event, state) if isinstance(event, AnthropicContentBlockDelta): return _chunks_for_content_delta(event, state) @@ -199,6 +209,13 @@ def _chunks_for_content_delta( if not is_json_delta: return [] + known_type = state.block_types.get(event.index) + if known_type is not None and known_type != "tool_use": + # A server tool's own arguments (DeepSeek streams the search query as + # ``input_json_delta`` on a ``server_tool_use`` block): nothing local + # asked for it, and its result arrived in this same response. + return [] + tool_state = state.ensure_tool(event.index) fragment = delta.partial_json if isinstance(delta.partial_json, str) else "" if not fragment: @@ -215,6 +232,8 @@ def _chunks_for_content_delta( async def _stream_as_chat_chunks( client: AnthropicClient, param: ChatCompletionsParam, + *, + provider_tools: Sequence[dict[str, Any]] | None = None, ) -> AsyncIterator[ChatCompletionChunk]: """Yield chat-completions chunks from an Anthropic Messages SSE stream. @@ -224,7 +243,11 @@ async def _stream_as_chat_chunks( ``message_stop``. Errors raise ``RuntimeError`` so the agent loop can surface them as retryable failures. """ - create = chat_param_to_anthropic_param(param, thinking_budget_tokens=_thinking_budget(client)) + create = chat_param_to_anthropic_param( + param, + thinking_budget_tokens=_thinking_budget(client), + provider_tools=provider_tools, + ) stream = await client.messages(create, stream=True) state = _StreamState(param.model) @@ -251,12 +274,22 @@ async def dispatch_messages( client: AnthropicClient, param: ChatCompletionsParam, *, + provider_tools: Sequence[dict[str, Any]] | None = None, stream: bool = False, ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: - """Run one Anthropic Messages turn and return a chat-completions-shaped result.""" - create = chat_param_to_anthropic_param(param, thinking_budget_tokens=_thinking_budget(client)) + """Run one Anthropic Messages turn and return a chat-completions-shaped result. + + *provider_tools* are hosted tools (web_search, …) as opaque dicts; the + surface's own server-tool definition is built from them and the results + arrive inside the same response, so nothing is executed locally. + """ + create = chat_param_to_anthropic_param( + param, + thinking_budget_tokens=_thinking_budget(client), + provider_tools=provider_tools, + ) if stream: - return _stream_as_chat_chunks(client, param) + return _stream_as_chat_chunks(client, param, provider_tools=provider_tools) response = await client.messages(create, stream=False) return anthropic_response_to_chat_completion(response) diff --git a/src/plyngent/lmproto/anthropic/model.py b/src/plyngent/lmproto/anthropic/model.py index 89dcb54..41135cd 100644 --- a/src/plyngent/lmproto/anthropic/model.py +++ b/src/plyngent/lmproto/anthropic/model.py @@ -2,12 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal from msgspec import UNSET, Struct, field -if TYPE_CHECKING: - from plyngent.typedef import Unset +from plyngent.typedef import Unset # noqa: TC001 class AnthropicTextContent(Struct, tag_field="type", tag="text"): @@ -79,7 +78,9 @@ class AnthropicMessagesParam(Struct, omit_defaults=True): max_tokens: int = 8192 messages: list[AnthropicMessage] = field(default_factory=list) system: str | list[AnthropicTextContent] | Unset = UNSET - tools: list[AnthropicToolDefinition] | Unset = UNSET + # ``dict`` entries are opaque hosted/server tools (e.g. DeepSeek's + # ``{"type": "web_search_20250305", "name": "web_search"}``). + tools: list[AnthropicToolDefinition | dict[str, Any]] | Unset = UNSET tool_choice: AnthropicToolChoice | Unset = UNSET metadata: AnthropicMetadata | Unset = UNSET stop_sequences: list[str] | Unset = UNSET @@ -107,7 +108,46 @@ class AnthropicResponseToolUse(Struct, tag_field="type", tag="tool_use"): input: dict[str, Any] -type AnthropicResponseContent = AnthropicResponseText | AnthropicResponseToolUse +class AnthropicThinkingContent(Struct, omit_defaults=True, tag_field="type", tag="thinking"): + """Extended-thinking block; ``signature`` is opaque and not verified back.""" + + thinking: str = "" + signature: str | Unset = UNSET + + +class AnthropicServerToolUseContent(Struct, omit_defaults=True, tag_field="type", tag="server_tool_use"): + """A tool the *server* runs — a web search the model asked its backend for. + + Not a call for the local registry: the matching + :class:`AnthropicWebSearchToolResultContent` arrives in the same response + (see the ``messages_dispatch`` / ``messages_bridge`` bullets in AGENTS.md). + """ + + id: str = "" + name: str = "" + input: dict[str, Any] = field(default_factory=dict) + + +class AnthropicWebSearchToolResultContent(Struct, omit_defaults=True, tag_field="type", tag="web_search_tool_result"): + """What the server's search returned for one ``server_tool_use`` call. + + ``content`` stays loose: it holds ``web_search_result`` entries (title, url + and an ``encrypted_content`` the API demands back unchanged — DeepSeek + answers a trimmed result with 422) or a single + ``web_search_tool_result_error`` such as ``max_uses_exceeded``. + """ + + tool_use_id: str = "" + content: list[dict[str, Any]] = field(default_factory=list) + + +type AnthropicResponseContent = ( + AnthropicResponseText + | AnthropicResponseToolUse + | AnthropicThinkingContent + | AnthropicServerToolUseContent + | AnthropicWebSearchToolResultContent +) class AnthropicMessageResponse(Struct, omit_defaults=True): diff --git a/tests/test_agent/test_messages_bridge.py b/tests/test_agent/test_messages_bridge.py index 63e0e8a..e770d09 100644 --- a/tests/test_agent/test_messages_bridge.py +++ b/tests/test_agent/test_messages_bridge.py @@ -13,6 +13,7 @@ from plyngent.agent.messages_bridge import ( anthropic_usage_to_dict, chat_messages_to_anthropic, chat_param_to_anthropic_param, + provider_tools_to_anthropic_tools, tool_items_to_anthropic_tools, ) from plyngent.lmproto.anthropic.model import ( @@ -20,10 +21,14 @@ from plyngent.lmproto.anthropic.model import ( AnthropicMessageResponse, AnthropicResponseText, AnthropicResponseToolUse, + AnthropicServerToolUseContent, + AnthropicThinkingContent, + AnthropicToolDefinition, AnthropicToolResultContent, AnthropicToolUseContent, AnthropicUsage, AnthropicUserMessage, + AnthropicWebSearchToolResultContent, ) from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, @@ -151,6 +156,69 @@ def test_anthropic_response_to_assistant_with_tools() -> None: assert '"a":1' in call.function.arguments or '"a": 1' in call.function.arguments +def test_anthropic_response_to_assistant_ignores_server_tool_blocks() -> None: + """A server-side search is not a call for the registry, nor part of the message.""" + response = AnthropicMessageResponse( + id="msg_search", + model="deepseek-flash", + stop_reason="end_turn", + content=[ + AnthropicThinkingContent(thinking="Let me search.", signature="sig-1"), + AnthropicServerToolUseContent(id="call_00_1", name="web_search", input={"query": "DeepSeek-V4"}), + AnthropicWebSearchToolResultContent( + tool_use_id="call_00_1", + content=[{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}], + ), + AnthropicResponseText(text="DeepSeek-V4 is out."), + ], + usage=AnthropicUsage(input_tokens=16592, output_tokens=1419), + ) + assistant = anthropic_response_to_assistant(response) + assert assistant.content == "DeepSeek-V4 is out." + assert assistant.tool_calls is UNSET + + +def test_provider_tools_to_anthropic_tools_maps_the_search_intent() -> None: + """The config default (``{"type": "web_search"}``) becomes the server tool.""" + assert provider_tools_to_anthropic_tools(None) == [] + assert provider_tools_to_anthropic_tools([]) == [] + assert provider_tools_to_anthropic_tools([{"type": "web_search"}]) == [ + {"type": "web_search_20250305", "name": "web_search"} + ] + # A cap (or any other key) survives the mapping. + assert provider_tools_to_anthropic_tools([{"type": "web_search", "max_uses": 3}]) == [ + {"type": "web_search_20250305", "name": "web_search", "max_uses": 3} + ] + # An entry that already names the surface's tool passes through. + assert provider_tools_to_anthropic_tools([{"type": "web_search_20260209", "name": "web_search"}]) == [ + {"type": "web_search_20260209", "name": "web_search"} + ] + # Entries without a type are dropped (nothing to declare on the wire). + assert provider_tools_to_anthropic_tools([{"name": "web_search"}]) == [] + + +def test_chat_param_to_anthropic_param_merges_provider_tools_after_local_ones() -> None: + param = ChatCompletionsParam( + model="deepseek-flash", + messages=[UserChatMessage(content="hi")], + tools=[ + ToolFunctionItem( + function=ToolFunction( + name="read_file", + description="Read a file.", + parameters={"type": "object", "properties": {}}, + ) + ) + ], + ) + built = chat_param_to_anthropic_param(param, provider_tools=[{"type": "web_search"}]) + tools = built.tools + assert tools is not UNSET + assert isinstance(tools[0], AnthropicToolDefinition) + assert tools[0].name == "read_file" + assert tools[1] == {"type": "web_search_20250305", "name": "web_search"} + + def test_anthropic_response_to_chat_completion_usage() -> None: response = AnthropicMessageResponse( id="msg_1", diff --git a/tests/test_agent/test_messages_dispatch.py b/tests/test_agent/test_messages_dispatch.py index 457dfff..0c0600a 100644 --- a/tests/test_agent/test_messages_dispatch.py +++ b/tests/test_agent/test_messages_dispatch.py @@ -20,6 +20,7 @@ from plyngent.agent.messages_dispatch import dispatch_messages from plyngent.lmproto.anthropic.model import ( AnthropicContentBlockDelta, AnthropicContentBlockStart, + AnthropicContentBlockStop, AnthropicMessageDelta, AnthropicMessageResponse, AnthropicMessagesParam, @@ -393,3 +394,106 @@ async def test_dispatch_stream_tool_calls_without_stop_yields_no_finish() -> Non chunks = [chunk async for chunk in cast("Any", stream)] # tool-call start delta only; no fabricated finish_reason=tool_calls assert not any(c.choices and c.choices[0].finish_reason not in (None, UNSET) for c in chunks) + + +def _search_stream_events() -> list[AnthropicStreamEvent]: + """The stream a DeepSeek search turn sends: thinking, search, results, text.""" + return [ + AnthropicMessageStart( + message=AnthropicMessageResponse( + id="msg_s", + model="deepseek-flash", + content=[], + usage=AnthropicUsage(input_tokens=164, output_tokens=0), + ) + ), + AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="thinking", text="")), + AnthropicContentBlockDelta( + index=0, + delta=AnthropicRawContentBlock(type="thinking_delta", text="Search."), + ), + AnthropicContentBlockStop(index=0), + AnthropicContentBlockStart( + index=1, + content_block=AnthropicRawContentBlock(type="server_tool_use", id="call_00_1", name="web_search", input={}), + ), + AnthropicContentBlockDelta( + index=1, + delta=AnthropicRawContentBlock(type="input_json_delta", partial_json='{"query":'), + ), + AnthropicContentBlockDelta( + index=1, + delta=AnthropicRawContentBlock(type="input_json_delta", partial_json='"DeepSeek-V4"}'), + ), + AnthropicContentBlockStop(index=1), + AnthropicContentBlockStart( + index=2, + content_block=AnthropicRawContentBlock( + type="web_search_tool_result", + id="call_00_1", + input={"content": [{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}]}, + ), + ), + AnthropicContentBlockStop(index=2), + AnthropicContentBlockStart(index=3, content_block=AnthropicRawContentBlock(type="text", text="")), + AnthropicContentBlockDelta( + index=3, + delta=AnthropicRawContentBlock(type="text_delta", text="DeepSeek-V4 is out."), + ), + AnthropicContentBlockStop(index=3), + AnthropicMessageDelta( + delta={"stop_reason": "end_turn"}, + usage=AnthropicUsage(input_tokens=0, output_tokens=12), + ), + AnthropicMessageStop(), + ] + + +async def test_search_turn_streams_its_text_without_a_local_tool_call() -> None: + """A server tool's arguments must not become a call for the registry. + + DeepSeek streams a ``server_tool_use`` block whose ``input_json_delta`` + fragments carry the search query. Reading those as a local call's arguments + asks the registry for a tool the *server* already ran — and would have the + agent execute ``web_search`` (or a nameless tool) itself. + """ + client = ScriptedMessagesClient(stream_events=[_search_stream_events()]) + + @tool(register=False) + def echo(text: str) -> str: + return text + + events = [ + event + async for event in run_chat_loop( + cast("Any", client), + [UserChatMessage(content="what is DeepSeek-V4?")], + model="deepseek-flash", + tools=ToolRegistry([echo]), + stream=True, + ) + ] + assert not any(isinstance(event, ToolCallEvent) for event in events) + assert not any(isinstance(event, ToolResultEvent) for event in events) + assert any(isinstance(event, TextDeltaEvent) and event.content == "DeepSeek-V4 is out." for event in events) + # One round: the search answered inside the response, so nothing was sent back. + assert len(client.calls) == 1 + + +async def test_dispatch_messages_sends_provider_tools_as_server_tools() -> None: + """Hosted tools reach the Anthropic request in that surface's own shape.""" + client = ScriptedMessagesClient(non_stream=[_message_response(text="ok")]) + param = ChatCompletionsParam(model="deepseek-flash", messages=[UserChatMessage(content="hi")]) + _ = await dispatch_messages( + cast("Any", client), + param, + provider_tools=[{"type": "web_search", "max_uses": 3}], + ) + assert client.calls[0].tools == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}] + + +async def test_dispatch_messages_without_provider_tools_sends_no_server_tool() -> None: + client = ScriptedMessagesClient(non_stream=[_message_response(text="ok")]) + param = ChatCompletionsParam(model="deepseek-flash", messages=[UserChatMessage(content="hi")]) + _ = await dispatch_messages(cast("Any", client), param, provider_tools=[]) + assert client.calls[0].tools is UNSET diff --git a/tests/test_lmproto/test_anthropic.py b/tests/test_lmproto/test_anthropic.py index fca3b4b..b4ed462 100644 --- a/tests/test_lmproto/test_anthropic.py +++ b/tests/test_lmproto/test_anthropic.py @@ -13,6 +13,7 @@ from plyngent.lmproto.anthropic.model import ( AnthropicResponseText, AnthropicUsage, AnthropicUserMessage, + AnthropicWebSearchToolResultContent, ) @@ -44,6 +45,75 @@ def test_messages_param_encode_omits_defaults() -> None: @pytest.mark.asyncio +async def test_client_decodes_a_server_side_search_response(monkeypatch: pytest.MonkeyPatch) -> None: + """DeepSeek's Anthropic compat answers a search turn with server tool blocks. + + A live turn reads ``thinking, server_tool_use, web_search_tool_result, text`` + (the results carry an ``encrypted_content`` the API demands back unchanged); + the response must decode, even though none of those blocks is a local call. + """ + body = { + "id": "msg_search", + "type": "message", + "role": "assistant", + "model": "deepseek-flash", + "stop_reason": "end_turn", + "content": [ + {"type": "thinking", "thinking": "Let me search.", "signature": "b032a4ad-87fe-42ce-801d-e33dd743d8b9"}, + { + "type": "server_tool_use", + "id": "call_00_joE6PEEC1otU8pvSe4zY3017", + "name": "web_search", + "input": {"query": "DeepSeek latest news"}, + "caller": {"type": "direct"}, + }, + { + "type": "web_search_tool_result", + "tool_use_id": "call_00_joE6PEEC1otU8pvSe4zY3017", + "content": [ + { + "type": "web_search_result", + "title": "Change Log | DeepSeek API Docs", + "url": "https://api-docs.deepseek.com/updates/", + "encrypted_content": "sd918tZXSvVp6j7ermvF4cl/lspbqHzths9SMQw6ziEw", + } + ], + }, + {"type": "text", "text": "DeepSeek shipped a new model."}, + ], + "usage": {"input_tokens": 16592, "output_tokens": 1419, "server_tool_use": {"web_search_requests": 4}}, + } + client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1")) + + class _Resp: + status_code = 200 + content = msgspec.json.encode(body) + + async def fake_post(path: str, **kwargs: object) -> _Resp: + return _Resp() + + monkeypatch.setattr(client.session, "post", fake_post) + result = await client.messages(AnthropicMessagesParam(model="deepseek-flash", messages=[])) + kinds = [block.__struct_config__.tag for block in result.content] + assert kinds == ["thinking", "server_tool_use", "web_search_tool_result", "text"] + search = result.content[2] + assert isinstance(search, AnthropicWebSearchToolResultContent) + assert search.tool_use_id == "call_00_joE6PEEC1otU8pvSe4zY3017" + assert search.content[0]["encrypted_content"].startswith("sd918tZXSvVp") + assert result.content[3].text == "DeepSeek shipped a new model." + + +def test_messages_param_encodes_server_tool_dicts() -> None: + """A hosted tool goes on the wire as the server-tool dict the surface wants.""" + param = AnthropicMessagesParam( + model="deepseek-flash", + messages=[AnthropicUserMessage(content="hi")], + tools=[{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}], + ) + data = msgspec.json.decode(msgspec.json.encode(param)) + assert data["tools"] == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}] + + async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None: client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))