mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/agent: keep a Messages round's provider blocks on the assistant message
DeepSeek's Anthropic compat answers a hosted search inside the turn: the response carries the `thinking` that picked the query, the `server_tool_use`, and the `web_search_tool_result`. The response mapping kept text and tool_use only, so the round lost its evidence — and the `thinking` signature, which only the provider can verify, could not go back at all. `AssistantChatMessage.provider_blocks` carries those blocks as raw JSON now (`thinking` text *and* signature, the query, the result entries with an `encrypted_content` the API rejects when trimmed) and the assistant turn hands them back first, ahead of its text and tool_use blocks; `thinking` also lands in `reasoning_content`, so the chain of thought streams as reasoning like every other surface's. `AnthropicRawContentBlock` names the provider fields a streamed block carries, and `AnthropicServerToolUseContent` keeps the `caller` metadata the wire sends.
This commit is contained in:
@@ -18,15 +18,17 @@ from plyngent.lmproto.anthropic.model import (
|
|||||||
AnthropicMessageResponse,
|
AnthropicMessageResponse,
|
||||||
AnthropicMessagesParam,
|
AnthropicMessagesParam,
|
||||||
AnthropicResponseText,
|
AnthropicResponseText,
|
||||||
AnthropicResponseToolUse,
|
AnthropicServerToolUseContent,
|
||||||
AnthropicTextContent,
|
AnthropicTextContent,
|
||||||
AnthropicThinkingConfig,
|
AnthropicThinkingConfig,
|
||||||
|
AnthropicThinkingContent,
|
||||||
AnthropicToolChoice,
|
AnthropicToolChoice,
|
||||||
AnthropicToolDefinition,
|
AnthropicToolDefinition,
|
||||||
AnthropicToolResultContent,
|
AnthropicToolResultContent,
|
||||||
AnthropicToolUseContent,
|
AnthropicToolUseContent,
|
||||||
AnthropicUsage,
|
AnthropicUsage,
|
||||||
AnthropicUserMessage,
|
AnthropicUserMessage,
|
||||||
|
AnthropicWebSearchToolResultContent,
|
||||||
)
|
)
|
||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
AnyAssistantToolCall,
|
AnyAssistantToolCall,
|
||||||
@@ -133,7 +135,16 @@ def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice |
|
|||||||
|
|
||||||
|
|
||||||
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
|
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
|
||||||
blocks: list[AnthropicTextContent | AnthropicToolUseContent] = []
|
"""The assistant turn for the wire: opaque blocks, then text, then calls.
|
||||||
|
|
||||||
|
``provider_blocks`` go back first and verbatim (a ``thinking`` block's
|
||||||
|
signature is only the provider's to verify, and a hosted search's
|
||||||
|
``web_search_tool_result`` entries carry an ``encrypted_content`` the API
|
||||||
|
rejects when trimmed), which is also the order the API sends them in.
|
||||||
|
"""
|
||||||
|
blocks: list[AnthropicTextContent | AnthropicToolUseContent | dict[str, Any]] = []
|
||||||
|
if message.provider_blocks is not UNSET and message.provider_blocks:
|
||||||
|
blocks.extend(message.provider_blocks)
|
||||||
if isinstance(message.content, str) and message.content:
|
if isinstance(message.content, str) and message.content:
|
||||||
blocks.append(AnthropicTextContent(text=message.content))
|
blocks.append(AnthropicTextContent(text=message.content))
|
||||||
if message.tool_calls is not UNSET and message.tool_calls:
|
if message.tool_calls is not UNSET and message.tool_calls:
|
||||||
@@ -219,22 +230,59 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
|
|||||||
return system, out
|
return system, out
|
||||||
|
|
||||||
|
|
||||||
|
def _thinking_block_json(block: AnthropicThinkingContent) -> dict[str, Any]:
|
||||||
|
"""A ``thinking`` block as JSON: its text, plus the signature when sent."""
|
||||||
|
out: dict[str, Any] = {"type": "thinking", "thinking": block.thinking}
|
||||||
|
if isinstance(block.signature, str):
|
||||||
|
out["signature"] = block.signature
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _server_block_json(
|
||||||
|
block: AnthropicServerToolUseContent | AnthropicWebSearchToolResultContent,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""A hosted search's block as JSON: the query it ran, or the raw entries.
|
||||||
|
|
||||||
|
Rebuilt from the parsed block: an ``encrypted_content`` (and any other key
|
||||||
|
inside the loose ``content`` entries) survives byte for byte, while a key
|
||||||
|
the block model does not name cannot come back.
|
||||||
|
"""
|
||||||
|
if isinstance(block, AnthropicServerToolUseContent):
|
||||||
|
out: dict[str, Any] = {"type": "server_tool_use", "id": block.id, "name": block.name, "input": block.input}
|
||||||
|
if block.caller is not UNSET:
|
||||||
|
out["caller"] = block.caller
|
||||||
|
return out
|
||||||
|
return {"type": "web_search_tool_result", "tool_use_id": block.tool_use_id, "content": block.content}
|
||||||
|
|
||||||
|
|
||||||
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`` lands in ``reasoning_content`` (the chat-shaped slot every
|
||||||
``thinking`` are not part of the chat-shaped message: a search the model ran
|
surface's chain-of-thought goes into) and, signature included, in
|
||||||
through its backend already shows up in the text it answered with.
|
``provider_blocks`` — the API wants the block back to continue a turn it
|
||||||
|
reasoned through. A hosted search's ``server_tool_use`` +
|
||||||
|
``web_search_tool_result`` pair is not a call for the local registry, but it
|
||||||
|
is the model's own evidence: both go into ``provider_blocks`` verbatim, so
|
||||||
|
the next request hands them back and the model does not search again.
|
||||||
"""
|
"""
|
||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
|
thinking_parts: list[str] = []
|
||||||
|
provider_blocks: list[dict[str, Any]] = []
|
||||||
tool_calls: list[AnyAssistantToolCall] = []
|
tool_calls: list[AnyAssistantToolCall] = []
|
||||||
for block in response.content:
|
for block in response.content:
|
||||||
if isinstance(block, AnthropicResponseText):
|
if isinstance(block, AnthropicResponseText):
|
||||||
if block.text:
|
if block.text:
|
||||||
text_parts.append(block.text)
|
text_parts.append(block.text)
|
||||||
continue
|
continue
|
||||||
if not isinstance(block, AnthropicResponseToolUse):
|
if isinstance(block, AnthropicThinkingContent):
|
||||||
|
thinking_parts.append(block.thinking)
|
||||||
|
provider_blocks.append(_thinking_block_json(block))
|
||||||
continue
|
continue
|
||||||
|
if isinstance(block, (AnthropicServerToolUseContent, AnthropicWebSearchToolResultContent)):
|
||||||
|
provider_blocks.append(_server_block_json(block))
|
||||||
|
continue
|
||||||
|
# The union's remaining member is the local tool call.
|
||||||
tool_calls.append(
|
tool_calls.append(
|
||||||
AssistantFunctionToolCall(
|
AssistantFunctionToolCall(
|
||||||
id=block.id,
|
id=block.id,
|
||||||
@@ -248,6 +296,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis
|
|||||||
return AssistantChatMessage(
|
return AssistantChatMessage(
|
||||||
content=text or None,
|
content=text or None,
|
||||||
tool_calls=tool_calls or UNSET,
|
tool_calls=tool_calls or UNSET,
|
||||||
|
reasoning_content="".join(thinking_parts) or UNSET,
|
||||||
|
provider_blocks=provider_blocks or UNSET,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,32 @@ def reasoning_delta_chunk(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def provider_blocks_chunk(
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
blocks: list[dict[str, Any]],
|
||||||
|
created: int = 0,
|
||||||
|
) -> ChatCompletionChunk:
|
||||||
|
"""Carry provider-opaque blocks a bridge rebuilt mid-stream.
|
||||||
|
|
||||||
|
The loop copies them onto the round's assistant message
|
||||||
|
(``AssistantChatMessage.provider_blocks``), which is what puts them back on
|
||||||
|
the wire for the surface that produced them.
|
||||||
|
"""
|
||||||
|
return ChatCompletionChunk(
|
||||||
|
id="bridge-stream",
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=created,
|
||||||
|
model=model,
|
||||||
|
choices=[
|
||||||
|
ChunkChoice(
|
||||||
|
index=0,
|
||||||
|
delta=DeltaMessage(provider_blocks=blocks),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def tool_call_delta_chunk(
|
def tool_call_delta_chunk(
|
||||||
*,
|
*,
|
||||||
model: str,
|
model: str,
|
||||||
@@ -127,6 +153,7 @@ def usage_chunk(*, model: str, usage: dict[str, Any], created: int = 0) -> ChatC
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"finish_reason_chunk",
|
"finish_reason_chunk",
|
||||||
|
"provider_blocks_chunk",
|
||||||
"reasoning_delta_chunk",
|
"reasoning_delta_chunk",
|
||||||
"text_delta_chunk",
|
"text_delta_chunk",
|
||||||
"tool_call_delta_chunk",
|
"tool_call_delta_chunk",
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ class AnthropicUserMessage(Struct, tag_field="role", tag="user"):
|
|||||||
|
|
||||||
|
|
||||||
class AnthropicAssistantMessage(Struct, tag_field="role", tag="assistant"):
|
class AnthropicAssistantMessage(Struct, tag_field="role", tag="assistant"):
|
||||||
content: str | list[AnthropicTextContent | AnthropicToolUseContent]
|
# ``dict`` entries are provider-opaque blocks echoed back verbatim: a
|
||||||
|
# ``thinking`` block (its ``signature`` cannot be rebuilt from the text) and
|
||||||
|
# the ``server_tool_use`` + ``web_search_tool_result`` pair a hosted search
|
||||||
|
# answers inside the turn.
|
||||||
|
content: str | list[AnthropicTextContent | AnthropicToolUseContent | dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage
|
type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage
|
||||||
@@ -125,11 +129,13 @@ class AnthropicServerToolUseContent(Struct, omit_defaults=True, tag_field="type"
|
|||||||
Not a call for the local registry: the matching
|
Not a call for the local registry: the matching
|
||||||
:class:`AnthropicWebSearchToolResultContent` arrives in the same response
|
:class:`AnthropicWebSearchToolResultContent` arrives in the same response
|
||||||
(see the ``messages_dispatch`` / ``messages_bridge`` bullets in AGENTS.md).
|
(see the ``messages_dispatch`` / ``messages_bridge`` bullets in AGENTS.md).
|
||||||
|
``caller`` is the surface's own metadata and goes back with the block.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str = ""
|
id: str = ""
|
||||||
name: str = ""
|
name: str = ""
|
||||||
input: dict[str, Any] = field(default_factory=dict)
|
input: dict[str, Any] = field(default_factory=dict)
|
||||||
|
caller: dict[str, Any] | Unset = UNSET
|
||||||
|
|
||||||
|
|
||||||
class AnthropicWebSearchToolResultContent(Struct, omit_defaults=True, tag_field="type", tag="web_search_tool_result"):
|
class AnthropicWebSearchToolResultContent(Struct, omit_defaults=True, tag_field="type", tag="web_search_tool_result"):
|
||||||
@@ -166,12 +172,27 @@ class AnthropicMessageResponse(Struct, omit_defaults=True):
|
|||||||
|
|
||||||
|
|
||||||
class AnthropicRawContentBlock(Struct, omit_defaults=True):
|
class AnthropicRawContentBlock(Struct, omit_defaults=True):
|
||||||
|
"""One streamed content block: the ``content_block`` / ``delta`` payload.
|
||||||
|
|
||||||
|
The fields cover the response union: ``text`` fragments, ``thinking`` and
|
||||||
|
its opaque ``signature``, a ``server_tool_use``'s ``input`` (streamed as
|
||||||
|
``partial_json``), and a ``web_search_tool_result``'s ``tool_use_id`` +
|
||||||
|
``content`` (kept loose — an entry's ``encrypted_content`` goes back
|
||||||
|
unchanged). ``agent.messages_dispatch`` rebuilds the blocks from these to
|
||||||
|
hand them to the next request.
|
||||||
|
"""
|
||||||
|
|
||||||
type: str = ""
|
type: str = ""
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
text: str | None = None
|
text: str | None = None
|
||||||
input: dict[str, Any] | None = None
|
input: dict[str, Any] | None = None
|
||||||
partial_json: str | None = None
|
partial_json: str | None = None
|
||||||
|
thinking: str | None = None
|
||||||
|
signature: str | None = None
|
||||||
|
tool_use_id: str | None = None
|
||||||
|
content: list[dict[str, Any]] | None = None
|
||||||
|
caller: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessageStart(Struct, tag_field="type", tag="message_start"):
|
class AnthropicMessageStart(Struct, tag_field="type", tag="message_start"):
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
|
|||||||
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
|
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
|
||||||
# OpenAI-compat / DeepSeek thinking streams (omitted when unset).
|
# OpenAI-compat / DeepSeek thinking streams (omitted when unset).
|
||||||
reasoning_content: str | Unset = UNSET
|
reasoning_content: str | Unset = UNSET
|
||||||
|
# Provider-opaque assistant blocks kept as raw JSON and echoed back verbatim
|
||||||
|
# by the surface that produced them (Anthropic: a ``thinking`` block, whose
|
||||||
|
# ``signature`` only the provider can verify, and the ``server_tool_use`` +
|
||||||
|
# ``web_search_tool_result`` pair a hosted search answers inside the turn).
|
||||||
|
# Surfaces that do not know them send the message without the field.
|
||||||
|
provider_blocks: list[dict[str, Any]] | Unset = UNSET
|
||||||
|
|
||||||
|
|
||||||
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
|
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
|
||||||
@@ -243,6 +249,10 @@ class DeltaMessage(Struct):
|
|||||||
# ``response.reasoning_text.done``), replacing any streamed fragments.
|
# ``response.reasoning_text.done``), replacing any streamed fragments.
|
||||||
reasoning_full: bool | Unset = UNSET
|
reasoning_full: bool | Unset = UNSET
|
||||||
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
|
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
|
||||||
|
# Provider-opaque blocks a bridge rebuilt mid-stream (see
|
||||||
|
# ``AssistantChatMessage.provider_blocks``): the loop copies them onto the
|
||||||
|
# round's assistant message.
|
||||||
|
provider_blocks: list[dict[str, Any]] | Unset = UNSET
|
||||||
|
|
||||||
|
|
||||||
class ChunkChoice(Struct):
|
class ChunkChoice(Struct):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import msgspec
|
||||||
from msgspec import UNSET
|
from msgspec import UNSET
|
||||||
|
|
||||||
from plyngent.agent.messages_bridge import (
|
from plyngent.agent.messages_bridge import (
|
||||||
@@ -156,15 +157,20 @@ 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:
|
def test_anthropic_response_to_assistant_keeps_thinking_and_search_blocks() -> None:
|
||||||
"""A server-side search is not a call for the registry, nor part of the message."""
|
"""A server-side search is not a registry call, but it is the round's evidence."""
|
||||||
response = AnthropicMessageResponse(
|
response = AnthropicMessageResponse(
|
||||||
id="msg_search",
|
id="msg_search",
|
||||||
model="deepseek-flash",
|
model="deepseek-flash",
|
||||||
stop_reason="end_turn",
|
stop_reason="end_turn",
|
||||||
content=[
|
content=[
|
||||||
AnthropicThinkingContent(thinking="Let me search.", signature="sig-1"),
|
AnthropicThinkingContent(thinking="Let me search.", signature="sig-1"),
|
||||||
AnthropicServerToolUseContent(id="call_00_1", name="web_search", input={"query": "DeepSeek-V4"}),
|
AnthropicServerToolUseContent(
|
||||||
|
id="call_00_1",
|
||||||
|
name="web_search",
|
||||||
|
input={"query": "DeepSeek-V4"},
|
||||||
|
caller={"type": "direct"},
|
||||||
|
),
|
||||||
AnthropicWebSearchToolResultContent(
|
AnthropicWebSearchToolResultContent(
|
||||||
tool_use_id="call_00_1",
|
tool_use_id="call_00_1",
|
||||||
content=[{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
|
content=[{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
|
||||||
@@ -176,6 +182,65 @@ def test_anthropic_response_to_assistant_ignores_server_tool_blocks() -> None:
|
|||||||
assistant = anthropic_response_to_assistant(response)
|
assistant = anthropic_response_to_assistant(response)
|
||||||
assert assistant.content == "DeepSeek-V4 is out."
|
assert assistant.content == "DeepSeek-V4 is out."
|
||||||
assert assistant.tool_calls is UNSET
|
assert assistant.tool_calls is UNSET
|
||||||
|
# The chain of thought is the round's reasoning text; the signature it must
|
||||||
|
# go back with only exists on the block.
|
||||||
|
assert assistant.reasoning_content == "Let me search."
|
||||||
|
assert assistant.provider_blocks == [
|
||||||
|
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
|
||||||
|
{
|
||||||
|
"type": "server_tool_use",
|
||||||
|
"id": "call_00_1",
|
||||||
|
"name": "web_search",
|
||||||
|
"input": {"query": "DeepSeek-V4"},
|
||||||
|
"caller": {"type": "direct"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "web_search_tool_result",
|
||||||
|
"tool_use_id": "call_00_1",
|
||||||
|
"content": [{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_param_to_anthropic_param_hands_provider_blocks_back() -> None:
|
||||||
|
"""The next request carries the round's thinking and search blocks first."""
|
||||||
|
assistant = AssistantChatMessage(
|
||||||
|
content="DeepSeek-V4 is out.",
|
||||||
|
reasoning_content="Let me search.",
|
||||||
|
provider_blocks=[
|
||||||
|
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
|
||||||
|
{
|
||||||
|
"type": "web_search_tool_result",
|
||||||
|
"tool_use_id": "call_00_1",
|
||||||
|
"content": [{"type": "web_search_result", "encrypted_content": "sd918tZXSvVp"}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool_calls=[
|
||||||
|
AssistantFunctionToolCall(
|
||||||
|
id="tu_1",
|
||||||
|
function=AssistantFunctionTool(name="read_file", arguments='{"path": "a.py"}'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
param = ChatCompletionsParam(model="deepseek-flash", messages=[assistant])
|
||||||
|
built = chat_param_to_anthropic_param(param)
|
||||||
|
# The wire shape: DeepSeek answers a trimmed ``encrypted_content`` with 422.
|
||||||
|
data = msgspec.json.decode(msgspec.json.encode(built))
|
||||||
|
assert data["messages"] == [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
|
||||||
|
{
|
||||||
|
"type": "web_search_tool_result",
|
||||||
|
"tool_use_id": "call_00_1",
|
||||||
|
"content": [{"type": "web_search_result", "encrypted_content": "sd918tZXSvVp"}],
|
||||||
|
},
|
||||||
|
{"type": "text", "text": "DeepSeek-V4 is out."},
|
||||||
|
{"type": "tool_use", "id": "tu_1", "name": "read_file", "input": {"path": "a.py"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_provider_tools_to_anthropic_tools_maps_the_search_intent() -> None:
|
def test_provider_tools_to_anthropic_tools_maps_the_search_intent() -> None:
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import pytest
|
|||||||
from plyngent.lmproto.anthropic import AnthropicClient
|
from plyngent.lmproto.anthropic import AnthropicClient
|
||||||
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
||||||
from plyngent.lmproto.anthropic.model import (
|
from plyngent.lmproto.anthropic.model import (
|
||||||
|
AnthropicAssistantMessage,
|
||||||
AnthropicContentBlockDelta,
|
AnthropicContentBlockDelta,
|
||||||
AnthropicMessageResponse,
|
AnthropicMessageResponse,
|
||||||
AnthropicMessagesParam,
|
AnthropicMessagesParam,
|
||||||
AnthropicMessageStop,
|
AnthropicMessageStop,
|
||||||
|
AnthropicRawContentBlock,
|
||||||
AnthropicResponseText,
|
AnthropicResponseText,
|
||||||
|
AnthropicTextContent,
|
||||||
AnthropicThinkingConfig,
|
AnthropicThinkingConfig,
|
||||||
AnthropicUsage,
|
AnthropicUsage,
|
||||||
AnthropicUserMessage,
|
AnthropicUserMessage,
|
||||||
@@ -126,6 +129,52 @@ def test_messages_param_keeps_the_thinking_type_on_the_wire() -> None:
|
|||||||
assert data["thinking"] == {"type": "enabled", "budget_tokens": 16384}
|
assert data["thinking"] == {"type": "enabled", "budget_tokens": 16384}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_block_keeps_the_provider_fields() -> None:
|
||||||
|
"""A streamed block carries what a rebuild puts back on the wire."""
|
||||||
|
result = msgspec.json.decode(
|
||||||
|
b'{"type":"web_search_tool_result","tool_use_id":"call_00_1",'
|
||||||
|
b'"content":[{"type":"web_search_result","encrypted_content":"sd9"}]}',
|
||||||
|
type=AnthropicRawContentBlock,
|
||||||
|
)
|
||||||
|
assert result.tool_use_id == "call_00_1"
|
||||||
|
assert result.content == [{"type": "web_search_result", "encrypted_content": "sd9"}]
|
||||||
|
thinking = msgspec.json.decode(
|
||||||
|
b'{"type":"thinking","thinking":"hmm","signature":"sig-1"}',
|
||||||
|
type=AnthropicRawContentBlock,
|
||||||
|
)
|
||||||
|
assert (thinking.thinking, thinking.signature) == ("hmm", "sig-1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_assistant_message_encodes_provider_blocks_verbatim() -> None:
|
||||||
|
"""An echoed block keeps its own keys — ``encrypted_content`` included."""
|
||||||
|
param = AnthropicMessagesParam(
|
||||||
|
model="deepseek-flash",
|
||||||
|
messages=[
|
||||||
|
AnthropicAssistantMessage(
|
||||||
|
content=[
|
||||||
|
{"type": "thinking", "thinking": "hmm", "signature": "sig-1"},
|
||||||
|
{
|
||||||
|
"type": "web_search_tool_result",
|
||||||
|
"tool_use_id": "call_00_1",
|
||||||
|
"content": [{"type": "web_search_result", "encrypted_content": "sd9"}],
|
||||||
|
},
|
||||||
|
AnthropicTextContent(text="done"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data = msgspec.json.decode(msgspec.json.encode(param))
|
||||||
|
assert data["messages"][0]["content"] == [
|
||||||
|
{"type": "thinking", "thinking": "hmm", "signature": "sig-1"},
|
||||||
|
{
|
||||||
|
"type": "web_search_tool_result",
|
||||||
|
"tool_use_id": "call_00_1",
|
||||||
|
"content": [{"type": "web_search_result", "encrypted_content": "sd9"}],
|
||||||
|
},
|
||||||
|
{"type": "text", "text": "done"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
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