mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/agent: run a hosted search as the surface's own tool
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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user