mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +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:
@@ -221,6 +221,7 @@ async def _dispatch_chat_completions(
|
|||||||
return await dispatch_messages(
|
return await dispatch_messages(
|
||||||
cast("AnthropicClient", client),
|
cast("AnthropicClient", client),
|
||||||
param,
|
param,
|
||||||
|
provider_tools=provider_tools,
|
||||||
stream=stream,
|
stream=stream,
|
||||||
)
|
)
|
||||||
msg = f"client kind {kind!r} is not supported by the agent loop"
|
msg = f"client kind {kind!r} is not supported by the agent loop"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from plyngent.lmproto.anthropic.model import (
|
|||||||
AnthropicMessageResponse,
|
AnthropicMessageResponse,
|
||||||
AnthropicMessagesParam,
|
AnthropicMessagesParam,
|
||||||
AnthropicResponseText,
|
AnthropicResponseText,
|
||||||
|
AnthropicResponseToolUse,
|
||||||
AnthropicTextContent,
|
AnthropicTextContent,
|
||||||
AnthropicThinkingConfig,
|
AnthropicThinkingConfig,
|
||||||
AnthropicToolChoice,
|
AnthropicToolChoice,
|
||||||
@@ -69,11 +70,11 @@ def _encode_tool_arguments(obj: dict[str, Any]) -> str:
|
|||||||
|
|
||||||
def tool_items_to_anthropic_tools(
|
def tool_items_to_anthropic_tools(
|
||||||
tools: Sequence[AnyToolItem] | None,
|
tools: Sequence[AnyToolItem] | None,
|
||||||
) -> list[AnthropicToolDefinition]:
|
) -> list[AnthropicToolDefinition | dict[str, Any]]:
|
||||||
"""Map chat ``ToolFunctionItem`` list to Anthropic tool definitions."""
|
"""Map chat ``ToolFunctionItem`` list to Anthropic tool definitions."""
|
||||||
if not tools:
|
if not tools:
|
||||||
return []
|
return []
|
||||||
result: list[AnthropicToolDefinition] = []
|
result: list[AnthropicToolDefinition | dict[str, Any]] = []
|
||||||
for item in tools:
|
for item in tools:
|
||||||
if not isinstance(item, ToolFunctionItem):
|
if not isinstance(item, ToolFunctionItem):
|
||||||
continue
|
continue
|
||||||
@@ -88,6 +89,35 @@ def tool_items_to_anthropic_tools(
|
|||||||
return result
|
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:
|
def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice | Unset:
|
||||||
if param.tool_choice is UNSET:
|
if param.tool_choice is UNSET:
|
||||||
return 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:
|
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] = []
|
text_parts: list[str] = []
|
||||||
tool_calls: list[AnyAssistantToolCall] = []
|
tool_calls: list[AnyAssistantToolCall] = []
|
||||||
for block in response.content:
|
for block in response.content:
|
||||||
@@ -198,7 +233,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis
|
|||||||
if block.text:
|
if block.text:
|
||||||
text_parts.append(block.text)
|
text_parts.append(block.text)
|
||||||
continue
|
continue
|
||||||
# AnthropicResponseToolUse (remaining content arm)
|
if not isinstance(block, AnthropicResponseToolUse):
|
||||||
|
continue
|
||||||
tool_calls.append(
|
tool_calls.append(
|
||||||
AssistantFunctionToolCall(
|
AssistantFunctionToolCall(
|
||||||
id=block.id,
|
id=block.id,
|
||||||
@@ -338,14 +374,20 @@ def chat_param_to_anthropic_param(
|
|||||||
param: ChatCompletionsParam,
|
param: ChatCompletionsParam,
|
||||||
*,
|
*,
|
||||||
thinking_budget_tokens: int = 0,
|
thinking_budget_tokens: int = 0,
|
||||||
|
provider_tools: Sequence[dict[str, Any]] | None = None,
|
||||||
) -> AnthropicMessagesParam:
|
) -> AnthropicMessagesParam:
|
||||||
"""Build :class:`AnthropicMessagesParam` from a chat completions param.
|
"""Build :class:`AnthropicMessagesParam` from a chat completions param.
|
||||||
|
|
||||||
*thinking_budget_tokens* is the configured explicit ``thinking`` budget
|
*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)
|
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)
|
max_tokens, thinking = _thinking_and_max_tokens(param, thinking_budget_tokens=thinking_budget_tokens)
|
||||||
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from plyngent.lmproto.anthropic.model import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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.client import AnthropicClient
|
||||||
from plyngent.lmproto.anthropic.model import AnthropicStreamEvent
|
from plyngent.lmproto.anthropic.model import AnthropicStreamEvent
|
||||||
@@ -92,6 +92,7 @@ class _StreamState:
|
|||||||
|
|
||||||
model: str
|
model: str
|
||||||
tool_blocks: dict[int, _ToolBlockState]
|
tool_blocks: dict[int, _ToolBlockState]
|
||||||
|
block_types: dict[int, str]
|
||||||
next_tool_index: int
|
next_tool_index: int
|
||||||
stop_reason: str | None
|
stop_reason: str | None
|
||||||
last_usage: dict[str, Any] | None
|
last_usage: dict[str, Any] | None
|
||||||
@@ -101,6 +102,7 @@ class _StreamState:
|
|||||||
def __init__(self, model: str) -> None:
|
def __init__(self, model: str) -> None:
|
||||||
self.model = model
|
self.model = model
|
||||||
self.tool_blocks = {}
|
self.tool_blocks = {}
|
||||||
|
self.block_types = {}
|
||||||
self.next_tool_index = 0
|
self.next_tool_index = 0
|
||||||
self.stop_reason = None
|
self.stop_reason = None
|
||||||
self.last_usage = 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="")
|
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,
|
event: AnthropicContentBlockStart,
|
||||||
state: _StreamState,
|
state: _StreamState,
|
||||||
) -> list[ChatCompletionChunk]:
|
) -> 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
|
block = event.content_block
|
||||||
|
state.block_types[event.index] = block.type
|
||||||
if block.type != "tool_use":
|
if block.type != "tool_use":
|
||||||
return []
|
return []
|
||||||
call_id = block.id or f"toolu_{event.index}"
|
call_id = block.id or f"toolu_{event.index}"
|
||||||
@@ -170,7 +180,7 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
if isinstance(event, AnthropicContentBlockStart):
|
if isinstance(event, AnthropicContentBlockStart):
|
||||||
return _handle_tool_use_start(event, state)
|
return _handle_block_start(event, state)
|
||||||
|
|
||||||
if isinstance(event, AnthropicContentBlockDelta):
|
if isinstance(event, AnthropicContentBlockDelta):
|
||||||
return _chunks_for_content_delta(event, state)
|
return _chunks_for_content_delta(event, state)
|
||||||
@@ -199,6 +209,13 @@ def _chunks_for_content_delta(
|
|||||||
if not is_json_delta:
|
if not is_json_delta:
|
||||||
return []
|
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)
|
tool_state = state.ensure_tool(event.index)
|
||||||
fragment = delta.partial_json if isinstance(delta.partial_json, str) else ""
|
fragment = delta.partial_json if isinstance(delta.partial_json, str) else ""
|
||||||
if not fragment:
|
if not fragment:
|
||||||
@@ -215,6 +232,8 @@ def _chunks_for_content_delta(
|
|||||||
async def _stream_as_chat_chunks(
|
async def _stream_as_chat_chunks(
|
||||||
client: AnthropicClient,
|
client: AnthropicClient,
|
||||||
param: ChatCompletionsParam,
|
param: ChatCompletionsParam,
|
||||||
|
*,
|
||||||
|
provider_tools: Sequence[dict[str, Any]] | None = None,
|
||||||
) -> AsyncIterator[ChatCompletionChunk]:
|
) -> AsyncIterator[ChatCompletionChunk]:
|
||||||
"""Yield chat-completions chunks from an Anthropic Messages SSE stream.
|
"""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
|
``message_stop``. Errors raise ``RuntimeError`` so the agent loop can
|
||||||
surface them as retryable failures.
|
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)
|
stream = await client.messages(create, stream=True)
|
||||||
state = _StreamState(param.model)
|
state = _StreamState(param.model)
|
||||||
|
|
||||||
@@ -251,12 +274,22 @@ async def dispatch_messages(
|
|||||||
client: AnthropicClient,
|
client: AnthropicClient,
|
||||||
param: ChatCompletionsParam,
|
param: ChatCompletionsParam,
|
||||||
*,
|
*,
|
||||||
|
provider_tools: Sequence[dict[str, Any]] | None = None,
|
||||||
stream: bool = False,
|
stream: bool = False,
|
||||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
"""Run one Anthropic Messages turn and return a chat-completions-shaped result."""
|
"""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))
|
|
||||||
|
*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:
|
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)
|
response = await client.messages(create, stream=False)
|
||||||
return anthropic_response_to_chat_completion(response)
|
return anthropic_response_to_chat_completion(response)
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from msgspec import UNSET, Struct, field
|
from msgspec import UNSET, Struct, field
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
from plyngent.typedef import Unset # noqa: TC001
|
||||||
from plyngent.typedef import Unset
|
|
||||||
|
|
||||||
|
|
||||||
class AnthropicTextContent(Struct, tag_field="type", tag="text"):
|
class AnthropicTextContent(Struct, tag_field="type", tag="text"):
|
||||||
@@ -79,7 +78,9 @@ class AnthropicMessagesParam(Struct, omit_defaults=True):
|
|||||||
max_tokens: int = 8192
|
max_tokens: int = 8192
|
||||||
messages: list[AnthropicMessage] = field(default_factory=list)
|
messages: list[AnthropicMessage] = field(default_factory=list)
|
||||||
system: str | list[AnthropicTextContent] | Unset = UNSET
|
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
|
tool_choice: AnthropicToolChoice | Unset = UNSET
|
||||||
metadata: AnthropicMetadata | Unset = UNSET
|
metadata: AnthropicMetadata | Unset = UNSET
|
||||||
stop_sequences: list[str] | 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]
|
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):
|
class AnthropicMessageResponse(Struct, omit_defaults=True):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from plyngent.agent.messages_bridge import (
|
|||||||
anthropic_usage_to_dict,
|
anthropic_usage_to_dict,
|
||||||
chat_messages_to_anthropic,
|
chat_messages_to_anthropic,
|
||||||
chat_param_to_anthropic_param,
|
chat_param_to_anthropic_param,
|
||||||
|
provider_tools_to_anthropic_tools,
|
||||||
tool_items_to_anthropic_tools,
|
tool_items_to_anthropic_tools,
|
||||||
)
|
)
|
||||||
from plyngent.lmproto.anthropic.model import (
|
from plyngent.lmproto.anthropic.model import (
|
||||||
@@ -20,10 +21,14 @@ from plyngent.lmproto.anthropic.model import (
|
|||||||
AnthropicMessageResponse,
|
AnthropicMessageResponse,
|
||||||
AnthropicResponseText,
|
AnthropicResponseText,
|
||||||
AnthropicResponseToolUse,
|
AnthropicResponseToolUse,
|
||||||
|
AnthropicServerToolUseContent,
|
||||||
|
AnthropicThinkingContent,
|
||||||
|
AnthropicToolDefinition,
|
||||||
AnthropicToolResultContent,
|
AnthropicToolResultContent,
|
||||||
AnthropicToolUseContent,
|
AnthropicToolUseContent,
|
||||||
AnthropicUsage,
|
AnthropicUsage,
|
||||||
AnthropicUserMessage,
|
AnthropicUserMessage,
|
||||||
|
AnthropicWebSearchToolResultContent,
|
||||||
)
|
)
|
||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
AssistantChatMessage,
|
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
|
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:
|
def test_anthropic_response_to_chat_completion_usage() -> None:
|
||||||
response = AnthropicMessageResponse(
|
response = AnthropicMessageResponse(
|
||||||
id="msg_1",
|
id="msg_1",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from plyngent.agent.messages_dispatch import dispatch_messages
|
|||||||
from plyngent.lmproto.anthropic.model import (
|
from plyngent.lmproto.anthropic.model import (
|
||||||
AnthropicContentBlockDelta,
|
AnthropicContentBlockDelta,
|
||||||
AnthropicContentBlockStart,
|
AnthropicContentBlockStart,
|
||||||
|
AnthropicContentBlockStop,
|
||||||
AnthropicMessageDelta,
|
AnthropicMessageDelta,
|
||||||
AnthropicMessageResponse,
|
AnthropicMessageResponse,
|
||||||
AnthropicMessagesParam,
|
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)]
|
chunks = [chunk async for chunk in cast("Any", stream)]
|
||||||
# tool-call start delta only; no fabricated finish_reason=tool_calls
|
# 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)
|
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,
|
AnthropicResponseText,
|
||||||
AnthropicUsage,
|
AnthropicUsage,
|
||||||
AnthropicUserMessage,
|
AnthropicUserMessage,
|
||||||
|
AnthropicWebSearchToolResultContent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +45,75 @@ def test_messages_param_encode_omits_defaults() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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:
|
async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
|
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user