mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 00:47:54 +08:00
Compare commits
8 Commits
v0.8.0
..
d3f8132b5d
| Author | SHA1 | Date | |
|---|---|---|---|
| d3f8132b5d | |||
| b0116a67c1 | |||
| f89c8dec31 | |||
| 7a54642ecf | |||
| 2fdbc3ce5d | |||
| 6c4c8b9328 | |||
| 279cbe9389 | |||
| 38e2dd58b0 |
@@ -50,7 +50,7 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
|
||||
- **`openai_compatible/config.py`** — `OpenAIConfig` (token + base URL).
|
||||
- **`openai/model.py`** — OpenAI Responses API (`ResponsesCreateParam`, `Response`, function_call items, stream events).
|
||||
- **`openai/client.py`** — platform `OpenAIClient` (`kind="responses"`): chat completions + `responses` / `get_response` / `delete_response`.
|
||||
- **`anthropic/model.py`** — Anthropic Messages request/response + SSE event models; the response content union also carries the server-tool blocks DeepSeek's compat returns (`thinking`, `server_tool_use`, `web_search_tool_result` with its verbatim `encrypted_content`), and `tools` accepts opaque server-tool dicts.
|
||||
- **`anthropic/model.py`** — Anthropic Messages request/response + SSE event models; the response content union also carries the server-tool blocks DeepSeek's compat returns (`thinking`, `server_tool_use`, `web_search_tool_result` with its verbatim `encrypted_content`), and `tools` accepts opaque server-tool dicts. `AnthropicRawContentBlock` names the same fields on the streaming payloads, and an assistant message's `content` accepts the opaque block dicts it echoes back.
|
||||
- **`anthropic/client.py`** — `AnthropicClient` (`kind="messages"`): `POST /messages` + `GET /models`.
|
||||
- **DeepSeek** — `DeepseekOpenAIClient` (`kind="chat_completions"`); models add `reasoning_content`, `prefix`, `ThinkingOptions`, and it injects the `thinking` flag (`enabled`, or `disabled` for `reasoning_effort = "none"`) after encoding, plus an empty `reasoning_content` on any assistant message that carries none (thinking mode refuses any round — a call or a plain answer — whose reasoning never came back). Config default model ids: `deepseek-flash`, `deepseek-v4-pro`. `DeepseekResponsesClient` (`kind="responses"`) extends the OpenAI Responses client for `convention = "responses"` (SSE stops at terminal events — DeepSeek sends no `[DONE]`). `DeepseekAnthropicClient` (`kind="messages"`) extends `AnthropicClient` for `convention = "anthropic"` (base `https://api.deepseek.com/anthropic`; `models()` returns `[]`).
|
||||
|
||||
@@ -78,8 +78,8 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
|
||||
- **Kind-based dispatch**: clients expose `kind` (`chat_completions` | `responses` | `messages`). Agent history stays chat-completions-shaped; transport adapters return synthetic `ChatCompletionResponse` / stream chunks.
|
||||
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
|
||||
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call, and the blocks are read (see `lmproto/anthropic/model.py`) but never executed.
|
||||
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results must be passed back verbatim if they are kept at all) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
||||
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
|
||||
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet).
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config).
|
||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
|
||||
|
||||
@@ -21,11 +21,13 @@ Tool playbook still applies (see system). Hard constraints:
|
||||
use `run_argv_batch` for ordered pipelines.
|
||||
- `edit_replace`: fix match / `max_replaces`; `edit_lineno` edits only lines read via \
|
||||
`read_file(with_lineno=true)`, and line numbers reset after any file write.
|
||||
- `read_file` results start with `L{begin}-{end}` (1-based lines; offset is 0-based).
|
||||
- `read_file` results start with `L{begin}-{end}` (1-based lines; offset is 0-based; \
|
||||
`skill_read` slices a skill file the same way).
|
||||
- `regex_files`: `pattern` is a Python `re` regex (not a glob, so `*.py` fails) and \
|
||||
`path` is a literal file/dir (no glob expansion).
|
||||
- Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
|
||||
- Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO).
|
||||
- Prefer `fetch` for HTTP(S); `offset` (0-based) skips leading body characters; \
|
||||
private/LAN hosts need human policy allow (not YOLO).
|
||||
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
|
||||
- `wait` pauses; pressing Enter disturbs early (optional reason).
|
||||
- Todo stack: open items mean unfinished work; the stack does not auto-clear — \
|
||||
|
||||
@@ -136,11 +136,16 @@ async def _execute_tool_calls(
|
||||
|
||||
|
||||
def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
|
||||
"""True if the model produced text, reasoning, or tool calls."""
|
||||
"""True if the model produced text, reasoning, provider blocks, or tool calls."""
|
||||
if assistant.tool_calls is not UNSET and assistant.tool_calls:
|
||||
return True
|
||||
if isinstance(assistant.content, str) and assistant.content.strip():
|
||||
return True
|
||||
# A round that only searched (a hosted ``web_search``) kept the search
|
||||
# blocks as provider blocks: the model did produce something, even with no
|
||||
# text of its own yet.
|
||||
if assistant.provider_blocks is not UNSET and assistant.provider_blocks:
|
||||
return True
|
||||
reasoning = assistant.reasoning_content
|
||||
return bool(isinstance(reasoning, str) and reasoning.strip())
|
||||
|
||||
@@ -283,6 +288,23 @@ def _take_reasoning_delta(
|
||||
return [content], [ReasoningDeltaEvent(content=fresh)] if fresh else []
|
||||
|
||||
|
||||
def _accumulate_deltas(
|
||||
delta: DeltaMessage,
|
||||
*,
|
||||
tool_deltas: list[StreamToolCallDelta],
|
||||
provider_blocks: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Collect the carriers a round keeps from one chunk's delta.
|
||||
|
||||
Tool calls merge after the stream; provider-opaque blocks (a hosted search's
|
||||
blocks, a thinking block) were rebuilt by the bridge and ride along as-is.
|
||||
"""
|
||||
if delta.tool_calls is not UNSET and delta.tool_calls:
|
||||
tool_deltas.extend(delta.tool_calls)
|
||||
if delta.provider_blocks is not UNSET and delta.provider_blocks:
|
||||
provider_blocks.extend(delta.provider_blocks)
|
||||
|
||||
|
||||
async def _stream_round(
|
||||
client: AnyLLMClient,
|
||||
param: ChatCompletionsParam,
|
||||
@@ -306,6 +328,7 @@ async def _stream_round(
|
||||
)
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
provider_blocks: list[dict[str, Any]] = []
|
||||
tool_deltas: list[StreamToolCallDelta] = []
|
||||
last_api_usage: object = UNSET
|
||||
finish_reason: str | None = None
|
||||
@@ -332,8 +355,7 @@ async def _stream_round(
|
||||
if isinstance(delta.content, str) and delta.content:
|
||||
content_parts.append(delta.content)
|
||||
yield TextDeltaEvent(content=delta.content)
|
||||
if delta.tool_calls is not UNSET and delta.tool_calls:
|
||||
tool_deltas.extend(delta.tool_calls)
|
||||
_accumulate_deltas(delta, tool_deltas=tool_deltas, provider_blocks=provider_blocks)
|
||||
|
||||
full_content = "".join(content_parts)
|
||||
full_reasoning = "".join(reasoning_parts)
|
||||
@@ -347,6 +369,7 @@ async def _stream_round(
|
||||
content=full_content or None,
|
||||
tool_calls=tool_calls,
|
||||
reasoning_content=full_reasoning or UNSET,
|
||||
provider_blocks=provider_blocks or UNSET,
|
||||
)
|
||||
# Usage-only final chunks leave choices empty; a non-empty usage after
|
||||
# content still is not a finish_reason. Prefer explicit finish_reason.
|
||||
|
||||
@@ -18,15 +18,17 @@ from plyngent.lmproto.anthropic.model import (
|
||||
AnthropicMessageResponse,
|
||||
AnthropicMessagesParam,
|
||||
AnthropicResponseText,
|
||||
AnthropicResponseToolUse,
|
||||
AnthropicServerToolUseContent,
|
||||
AnthropicTextContent,
|
||||
AnthropicThinkingConfig,
|
||||
AnthropicThinkingContent,
|
||||
AnthropicToolChoice,
|
||||
AnthropicToolDefinition,
|
||||
AnthropicToolResultContent,
|
||||
AnthropicToolUseContent,
|
||||
AnthropicUsage,
|
||||
AnthropicUserMessage,
|
||||
AnthropicWebSearchToolResultContent,
|
||||
)
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AnyAssistantToolCall,
|
||||
@@ -133,7 +135,19 @@ def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice |
|
||||
|
||||
|
||||
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. A live
|
||||
probe takes that shape back — the round after a search, with and without a
|
||||
local ``tool_use`` after the blocks — and the model then answers from the
|
||||
results it already holds instead of searching again.
|
||||
"""
|
||||
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:
|
||||
blocks.append(AnthropicTextContent(text=message.content))
|
||||
if message.tool_calls is not UNSET and message.tool_calls:
|
||||
@@ -219,22 +233,59 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
|
||||
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:
|
||||
"""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.
|
||||
``thinking`` lands in ``reasoning_content`` (the chat-shaped slot every
|
||||
surface's chain-of-thought goes into) and, signature included, in
|
||||
``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] = []
|
||||
thinking_parts: list[str] = []
|
||||
provider_blocks: list[dict[str, Any]] = []
|
||||
tool_calls: list[AnyAssistantToolCall] = []
|
||||
for block in response.content:
|
||||
if isinstance(block, AnthropicResponseText):
|
||||
if block.text:
|
||||
text_parts.append(block.text)
|
||||
continue
|
||||
if not isinstance(block, AnthropicResponseToolUse):
|
||||
if isinstance(block, AnthropicThinkingContent):
|
||||
thinking_parts.append(block.thinking)
|
||||
provider_blocks.append(_thinking_block_json(block))
|
||||
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(
|
||||
AssistantFunctionToolCall(
|
||||
id=block.id,
|
||||
@@ -248,6 +299,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis
|
||||
return AssistantChatMessage(
|
||||
content=text or None,
|
||||
tool_calls=tool_calls or UNSET,
|
||||
reasoning_content="".join(thinking_parts) or UNSET,
|
||||
provider_blocks=provider_blocks or UNSET,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ enter it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
from plyngent.agent.messages_bridge import (
|
||||
anthropic_response_to_chat_completion,
|
||||
@@ -18,6 +19,8 @@ from plyngent.agent.messages_bridge import (
|
||||
)
|
||||
from plyngent.agent.stream_chunks import (
|
||||
finish_reason_chunk,
|
||||
provider_blocks_chunk,
|
||||
reasoning_delta_chunk,
|
||||
text_delta_chunk,
|
||||
tool_call_delta_chunk,
|
||||
usage_chunk,
|
||||
@@ -25,6 +28,7 @@ from plyngent.agent.stream_chunks import (
|
||||
from plyngent.lmproto.anthropic.model import (
|
||||
AnthropicContentBlockDelta,
|
||||
AnthropicContentBlockStart,
|
||||
AnthropicContentBlockStop,
|
||||
AnthropicErrorEvent,
|
||||
AnthropicMessageDelta,
|
||||
AnthropicMessageStart,
|
||||
@@ -65,6 +69,92 @@ class _ToolBlockState:
|
||||
self.tool_index = tool_index
|
||||
|
||||
|
||||
# Block types the chat-shaped round carries itself: text becomes the message's
|
||||
# content and ``tool_use`` a local tool call. Every other type is the surface's
|
||||
# own (``thinking``, ``server_tool_use``, ``web_search_tool_result``) and is
|
||||
# rebuilt so the round can hand it back.
|
||||
_LOCAL_BLOCK_TYPES: Final = frozenset({"text", "tool_use"})
|
||||
|
||||
# The two fields a *delta* owns; a block's own JSON skips them.
|
||||
_DELTA_ONLY_FIELDS: Final = frozenset({"text", "partial_json"})
|
||||
|
||||
|
||||
def _raw_block_json(block: AnthropicRawContentBlock) -> dict[str, Any]:
|
||||
"""The block's own JSON: every field the stream set, under its own name.
|
||||
|
||||
Field-driven, so a field added to the block model comes along without a
|
||||
second list to keep in sync.
|
||||
"""
|
||||
out: dict[str, Any] = {"type": block.type}
|
||||
for name in block.__struct_fields__:
|
||||
if name in _DELTA_ONLY_FIELDS:
|
||||
continue
|
||||
value: object = getattr(block, name)
|
||||
if value is not None:
|
||||
out[name] = value
|
||||
return out
|
||||
|
||||
|
||||
def _parse_input_fragments(fragments: list[str]) -> dict[str, Any]:
|
||||
"""The ``input`` a ``server_tool_use`` streamed as ``partial_json`` chunks."""
|
||||
try:
|
||||
parsed = json.loads("".join(fragments))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if isinstance(parsed, dict):
|
||||
return cast("dict[str, Any]", parsed)
|
||||
return {}
|
||||
|
||||
|
||||
class _ProviderBlockState:
|
||||
"""A surface-owned block rebuilt from its start payload and fragments.
|
||||
|
||||
Nothing local asked for it, but it goes back on the wire with the round (see
|
||||
``AssistantChatMessage.provider_blocks``): a ``thinking`` block's text and
|
||||
its opaque ``signature``, and the search query a ``server_tool_use`` streams
|
||||
as ``partial_json``.
|
||||
"""
|
||||
|
||||
__slots__: Final = ("json_fragments", "parts", "raw", "signature")
|
||||
raw: dict[str, Any]
|
||||
parts: list[str]
|
||||
json_fragments: list[str]
|
||||
signature: str
|
||||
|
||||
def __init__(self, raw: dict[str, Any]) -> None:
|
||||
self.raw = raw
|
||||
self.parts = []
|
||||
self.json_fragments = []
|
||||
self.signature = ""
|
||||
|
||||
def add(self, delta: AnthropicRawContentBlock) -> str | None:
|
||||
"""Fold one fragment in; return the thinking text it carried, if any."""
|
||||
if isinstance(delta.partial_json, str) and delta.partial_json:
|
||||
self.json_fragments.append(delta.partial_json)
|
||||
return None
|
||||
if isinstance(delta.signature, str) and delta.signature:
|
||||
self.signature = delta.signature
|
||||
return None
|
||||
# Anthropic streams a thinking fragment as ``thinking``; DeepSeek's
|
||||
# compat also spells the same fragment ``text``.
|
||||
fragment = delta.thinking if isinstance(delta.thinking, str) else delta.text
|
||||
if not isinstance(fragment, str) or not fragment:
|
||||
return None
|
||||
self.parts.append(fragment)
|
||||
return fragment
|
||||
|
||||
def finish(self) -> dict[str, Any]:
|
||||
"""The block to hand back, fragments folded into its start payload."""
|
||||
block = dict(self.raw)
|
||||
if self.parts:
|
||||
block["thinking"] = f"{block.get('thinking', '')}{''.join(self.parts)}"
|
||||
if self.json_fragments:
|
||||
block["input"] = _parse_input_fragments(self.json_fragments)
|
||||
if self.signature:
|
||||
block["signature"] = self.signature
|
||||
return block
|
||||
|
||||
|
||||
def _merge_usage(previous: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge ``message_start`` usage with the later ``message_delta`` usage.
|
||||
|
||||
@@ -99,6 +189,7 @@ class _StreamState:
|
||||
model: str
|
||||
tool_blocks: dict[int, _ToolBlockState]
|
||||
block_types: dict[int, str]
|
||||
provider_blocks: dict[int, _ProviderBlockState]
|
||||
next_tool_index: int
|
||||
stop_reason: str | None
|
||||
last_usage: dict[str, Any] | None
|
||||
@@ -109,6 +200,7 @@ class _StreamState:
|
||||
self.model = model
|
||||
self.tool_blocks = {}
|
||||
self.block_types = {}
|
||||
self.provider_blocks = {}
|
||||
self.next_tool_index = 0
|
||||
self.stop_reason = None
|
||||
self.last_usage = None
|
||||
@@ -140,13 +232,17 @@ def _handle_block_start(
|
||||
) -> 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``).
|
||||
A surface-owned block (``server_tool_use``, its ``web_search_tool_result``,
|
||||
``thinking``) is rebuilt from here on so the round can hand it back (see
|
||||
``AssistantChatMessage.provider_blocks``); recording its type also keeps the
|
||||
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 not in _LOCAL_BLOCK_TYPES:
|
||||
state.provider_blocks[event.index] = _ProviderBlockState(_raw_block_json(block))
|
||||
return []
|
||||
if block.type != "tool_use":
|
||||
return []
|
||||
call_id = block.id or f"toolu_{event.index}"
|
||||
@@ -172,6 +268,17 @@ def _handle_message_delta(event: AnthropicMessageDelta, state: _StreamState) ->
|
||||
state.last_usage = _merge_usage(state.last_usage, usage)
|
||||
|
||||
|
||||
def _chunks_for_block_stop(
|
||||
event: AnthropicContentBlockStop,
|
||||
state: _StreamState,
|
||||
) -> list[ChatCompletionChunk]:
|
||||
"""Emit a surface-owned block once its fragments are folded in."""
|
||||
provider = state.provider_blocks.pop(event.index, None)
|
||||
if provider is None:
|
||||
return []
|
||||
return [provider_blocks_chunk(model=state.model, blocks=[provider.finish()])]
|
||||
|
||||
|
||||
def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[ChatCompletionChunk]:
|
||||
"""Convert one Anthropic stream event into zero or more chat chunks."""
|
||||
if isinstance(event, AnthropicErrorEvent):
|
||||
@@ -188,6 +295,9 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
|
||||
if isinstance(event, AnthropicContentBlockStart):
|
||||
return _handle_block_start(event, state)
|
||||
|
||||
if isinstance(event, AnthropicContentBlockStop):
|
||||
return _chunks_for_block_stop(event, state)
|
||||
|
||||
if isinstance(event, AnthropicContentBlockDelta):
|
||||
return _chunks_for_content_delta(event, state)
|
||||
|
||||
@@ -201,11 +311,31 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
|
||||
return []
|
||||
|
||||
|
||||
def _chunks_for_provider_delta(
|
||||
delta: AnthropicRawContentBlock,
|
||||
provider: _ProviderBlockState,
|
||||
*,
|
||||
model: str,
|
||||
) -> list[ChatCompletionChunk]:
|
||||
"""One fragment of a surface-owned block: its reasoning text, or nothing."""
|
||||
fragment = provider.add(delta)
|
||||
if fragment is None:
|
||||
return []
|
||||
return [reasoning_delta_chunk(model=model, content=fragment)]
|
||||
|
||||
|
||||
def _chunks_for_content_delta(
|
||||
event: AnthropicContentBlockDelta,
|
||||
state: _StreamState,
|
||||
) -> list[ChatCompletionChunk]:
|
||||
delta: AnthropicRawContentBlock = event.delta
|
||||
provider = state.provider_blocks.get(event.index)
|
||||
if provider is not None:
|
||||
# A surface-owned block's fragments never become local output here: the
|
||||
# thinking text streams as reasoning, while the search query and the
|
||||
# signature only go back with the round.
|
||||
return _chunks_for_provider_delta(delta, provider, model=state.model)
|
||||
|
||||
if delta.type in {"text_delta", "text"} and isinstance(delta.text, str) and delta.text:
|
||||
return [text_delta_chunk(model=state.model, content=delta.text)]
|
||||
|
||||
|
||||
@@ -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(
|
||||
*,
|
||||
model: str,
|
||||
@@ -127,6 +153,7 @@ def usage_chunk(*, model: str, usage: dict[str, Any], created: int = 0) -> ChatC
|
||||
|
||||
__all__ = [
|
||||
"finish_reason_chunk",
|
||||
"provider_blocks_chunk",
|
||||
"reasoning_delta_chunk",
|
||||
"text_delta_chunk",
|
||||
"tool_call_delta_chunk",
|
||||
|
||||
@@ -52,7 +52,8 @@ raise `max_replaces` or narrow `old_string`.
|
||||
re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete, \
|
||||
since line numbers go stale.
|
||||
- `read_file` results start with a 1-based line range `L{begin}-{end}` (`offset` \
|
||||
is 0-based); `with_lineno` shows per-line numbers instead.
|
||||
is 0-based); `with_lineno` shows per-line numbers instead. `skill_read` slices a \
|
||||
skill file the same way.
|
||||
- `regex_files` takes a Python `re` regex for `pattern`, not a filename glob \
|
||||
(`*.py` / `**/*.py` fail with `invalid regex`), and a literal file/dir `path` \
|
||||
(no glob expansion, so `src/**/*.py` fails with `path does not exist`); there is \
|
||||
@@ -72,7 +73,8 @@ for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
|
||||
- Prefer `vcs_*` for status/diff/log/branch when enough.
|
||||
|
||||
### Network
|
||||
- Prefer `fetch` (GET/POST/PUT/DELETE) for HTTP(S) docs/APIs over curl/wget via shell.
|
||||
- Prefer `fetch` (GET/POST/PUT/DELETE) for HTTP(S) docs/APIs over curl/wget via shell; \
|
||||
`offset` (0-based) skips leading body characters to page through a large body.
|
||||
- Set `user_agent` (or a User-Agent header) when the remote expects a specific client; \
|
||||
otherwise a small default is used. Never rely on shell to spoof identity.
|
||||
- Private/loopback/LAN hosts need an explicit human policy allow (not skipped by YOLO).
|
||||
|
||||
@@ -39,7 +39,11 @@ class AnthropicUserMessage(Struct, tag_field="role", tag="user"):
|
||||
|
||||
|
||||
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
|
||||
@@ -125,11 +129,13 @@ class AnthropicServerToolUseContent(Struct, omit_defaults=True, tag_field="type"
|
||||
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).
|
||||
``caller`` is the surface's own metadata and goes back with the block.
|
||||
"""
|
||||
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
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"):
|
||||
@@ -166,12 +172,27 @@ class AnthropicMessageResponse(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 = ""
|
||||
id: str | None = None
|
||||
name: str | None = None
|
||||
text: str | None = None
|
||||
input: dict[str, Any] | 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"):
|
||||
|
||||
@@ -5,8 +5,9 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import msgspec
|
||||
from msgspec import UNSET
|
||||
|
||||
from .model import DeveloperChatMessage, SystemChatMessage
|
||||
from .model import AssistantChatMessage, DeveloperChatMessage, SystemChatMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
@@ -37,9 +38,31 @@ def coerce_developer_messages_to_system(messages: Sequence[AnyChatMessage]) -> l
|
||||
return out if changed else list(messages)
|
||||
|
||||
|
||||
def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
|
||||
"""Return *messages* as a chat-completions wire can take them.
|
||||
|
||||
Both normalizations are for broad provider compatibility: a ``developer``
|
||||
role becomes ``system`` (see :func:`coerce_developer_messages_to_system`),
|
||||
and ``AssistantChatMessage.provider_blocks`` is dropped — those blocks
|
||||
belong to the surface that produced them (Anthropic ``thinking`` and
|
||||
server-tool blocks; see ``agent.messages_bridge``) and no other provider
|
||||
knows the key.
|
||||
"""
|
||||
coerced = coerce_developer_messages_to_system(messages)
|
||||
changed = False
|
||||
out: list[AnyChatMessage] = []
|
||||
for message in coerced:
|
||||
if isinstance(message, AssistantChatMessage) and message.provider_blocks is not UNSET:
|
||||
out.append(msgspec.structs.replace(message, provider_blocks=UNSET))
|
||||
changed = True
|
||||
else:
|
||||
out.append(message)
|
||||
return out if changed else coerced
|
||||
|
||||
|
||||
def coerce_chat_completions_param(param: ChatCompletionsParam) -> ChatCompletionsParam:
|
||||
"""Normalize a chat-completions request for broad provider compatibility."""
|
||||
messages = coerce_developer_messages_to_system(param.messages)
|
||||
messages = coerce_chat_messages(param.messages)
|
||||
if messages == param.messages:
|
||||
return param
|
||||
return msgspec.structs.replace(param, messages=messages)
|
||||
@@ -56,7 +79,7 @@ def coerce_chat_completions_param_any(param: Any) -> Any:
|
||||
if not isinstance(messages_obj, list):
|
||||
return param
|
||||
messages = cast("list[AnyChatMessage]", messages_obj)
|
||||
coerced = coerce_developer_messages_to_system(messages)
|
||||
coerced = coerce_chat_messages(messages)
|
||||
if coerced == messages:
|
||||
return param
|
||||
return msgspec.structs.replace(param, messages=coerced)
|
||||
|
||||
@@ -74,6 +74,12 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
|
||||
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
|
||||
# OpenAI-compat / DeepSeek thinking streams (omitted when 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"):
|
||||
@@ -243,6 +249,10 @@ class DeltaMessage(Struct):
|
||||
# ``response.reasoning_text.done``), replacing any streamed fragments.
|
||||
reasoning_full: bool | 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):
|
||||
|
||||
@@ -11,6 +11,9 @@ if TYPE_CHECKING:
|
||||
# How many unread line numbers to preview in the error message.
|
||||
_UNREAD_PREVIEW = 8
|
||||
|
||||
# Lines of context a suggested read covers on each side of the edit range.
|
||||
_READ_HINT_MARGIN = 3
|
||||
|
||||
|
||||
def _detect_newline(lines: list[str]) -> str:
|
||||
for sample in lines:
|
||||
@@ -68,6 +71,19 @@ def _unread_lines_in_range(
|
||||
return sorted(set(range(start_line, end_line + 1)) - read_lines)
|
||||
|
||||
|
||||
def _read_hint(path: str, start_line: int, end_line: int, n: int) -> str:
|
||||
"""A ready-to-copy ``read_file`` call covering the edit range plus a margin.
|
||||
|
||||
``read_file`` counts ``offset`` from 0 while ``edit_lineno`` counts lines
|
||||
from 1, so the call is spelled out instead of asking the model to convert.
|
||||
The window is clipped to the file (``_validate_range`` already accepted the
|
||||
edit) and the margin keeps neighbouring lines editable too.
|
||||
"""
|
||||
first = max(1, start_line - _READ_HINT_MARGIN)
|
||||
last = max(first, min(n, end_line + _READ_HINT_MARGIN))
|
||||
return f"read_file(path={path!r}, offset={first - 1}, limit={last - first + 1}, with_lineno=true)"
|
||||
|
||||
|
||||
def _append_after(target: Path, path: str, text: str, lines: list[str], new_content: str) -> str:
|
||||
n = len(lines)
|
||||
newline = _detect_newline(lines[-1:] if lines else [])
|
||||
@@ -135,8 +151,9 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
|
||||
if read_lines:
|
||||
window = f" (this turn read lines {min(read_lines)}-{max(read_lines)})"
|
||||
return (
|
||||
f"error: lines {head} not read{window}; call read_file(path, with_lineno=true) "
|
||||
"first — read with a small margin around the lines you plan to edit"
|
||||
f"error: lines {head} not read{window}; re-read with "
|
||||
f"{_read_hint(path, start_line, end_line, len(lines))} "
|
||||
"— offset is 0-based; the window keeps a margin around the edit"
|
||||
)
|
||||
|
||||
recorded_mtime = lineno_read_mtime(str(target))
|
||||
@@ -148,7 +165,8 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
|
||||
if current_mtime is not None and current_mtime != recorded_mtime:
|
||||
return (
|
||||
f"error: {path} changed since it was read (line numbers may be stale); "
|
||||
"re-read with read_file(path, with_lineno=true)"
|
||||
f"re-read with {_read_hint(path, start_line, end_line, len(lines))} "
|
||||
"— offset is 0-based"
|
||||
)
|
||||
|
||||
if start_line == len(lines) + 1:
|
||||
|
||||
@@ -80,6 +80,10 @@ async def fetch(
|
||||
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
|
||||
|
||||
Use for public docs/APIs or (after human policy allow) local/LAN servers.
|
||||
``offset`` skips that many leading characters of the body (0-based), for
|
||||
paging through a body larger than ``max_chars``; a body truncated by
|
||||
``max_chars`` keeps its remainder in memory instead, resumable via its
|
||||
``truncate_token`` without another request.
|
||||
``user_agent`` sets User-Agent when provided and takes precedence over a
|
||||
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
|
||||
When both omit UA, a small default is used.
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import msgspec
|
||||
from msgspec import UNSET
|
||||
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
def test_anthropic_response_to_assistant_keeps_thinking_and_search_blocks() -> None:
|
||||
"""A server-side search is not a registry call, but it is the round's evidence."""
|
||||
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"}),
|
||||
AnthropicServerToolUseContent(
|
||||
id="call_00_1",
|
||||
name="web_search",
|
||||
input={"query": "DeepSeek-V4"},
|
||||
caller={"type": "direct"},
|
||||
),
|
||||
AnthropicWebSearchToolResultContent(
|
||||
tool_use_id="call_00_1",
|
||||
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)
|
||||
assert assistant.content == "DeepSeek-V4 is out."
|
||||
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:
|
||||
|
||||
@@ -8,6 +8,7 @@ from msgspec import UNSET
|
||||
|
||||
from plyngent.agent import (
|
||||
AssistantMessageEvent,
|
||||
ReasoningDeltaEvent,
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolRegistry,
|
||||
@@ -396,9 +397,9 @@ async def test_dispatch_stream_tool_calls_without_stop_yields_no_finish() -> Non
|
||||
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]:
|
||||
def _search_stream_events(*, text: bool = True) -> list[AnthropicStreamEvent]:
|
||||
"""The stream a DeepSeek search turn sends: thinking, search, results, text."""
|
||||
return [
|
||||
events: list[AnthropicStreamEvent] = [
|
||||
AnthropicMessageStart(
|
||||
message=AnthropicMessageResponse(
|
||||
id="msg_s",
|
||||
@@ -407,11 +408,16 @@ def _search_stream_events() -> list[AnthropicStreamEvent]:
|
||||
usage=AnthropicUsage(input_tokens=164, output_tokens=0),
|
||||
)
|
||||
),
|
||||
AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="thinking", text="")),
|
||||
AnthropicContentBlockStart(
|
||||
index=0,
|
||||
content_block=AnthropicRawContentBlock(type="thinking", thinking=""),
|
||||
),
|
||||
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="thinking_delta", thinking="Search ")),
|
||||
AnthropicContentBlockDelta(
|
||||
index=0,
|
||||
delta=AnthropicRawContentBlock(type="thinking_delta", text="Search."),
|
||||
delta=AnthropicRawContentBlock(type="thinking_delta", thinking="the docs."),
|
||||
),
|
||||
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="signature_delta", signature="sig-1")),
|
||||
AnthropicContentBlockStop(index=0),
|
||||
AnthropicContentBlockStart(
|
||||
index=1,
|
||||
@@ -430,23 +436,39 @@ def _search_stream_events() -> list[AnthropicStreamEvent]:
|
||||
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"}]},
|
||||
tool_use_id="call_00_1",
|
||||
content=[
|
||||
{
|
||||
"type": "web_search_result",
|
||||
"url": "https://api-docs.deepseek.com",
|
||||
"encrypted_content": "sd918tZXSvVp",
|
||||
}
|
||||
],
|
||||
),
|
||||
),
|
||||
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(),
|
||||
]
|
||||
if text:
|
||||
events.extend(
|
||||
[
|
||||
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),
|
||||
]
|
||||
)
|
||||
events.extend(
|
||||
[
|
||||
AnthropicMessageDelta(
|
||||
delta={"stop_reason": "end_turn"},
|
||||
usage=AnthropicUsage(input_tokens=0, output_tokens=12),
|
||||
),
|
||||
AnthropicMessageStop(),
|
||||
]
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
async def test_search_turn_streams_its_text_without_a_local_tool_call() -> None:
|
||||
@@ -476,10 +498,58 @@ async def test_search_turn_streams_its_text_without_a_local_tool_call() -> None:
|
||||
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)
|
||||
# The chain of thought streams as reasoning, fragment by fragment.
|
||||
assert [event.content for event in events if isinstance(event, ReasoningDeltaEvent)] == ["Search ", "the docs."]
|
||||
# ...and the round keeps the search blocks, rebuilt, for the next request.
|
||||
assistant = next(event.message for event in events if isinstance(event, AssistantMessageEvent))
|
||||
assert assistant.reasoning_content == "Search the docs."
|
||||
assert assistant.provider_blocks == [
|
||||
{"type": "thinking", "thinking": "Search the docs.", "signature": "sig-1"},
|
||||
{
|
||||
"type": "server_tool_use",
|
||||
"id": "call_00_1",
|
||||
"name": "web_search",
|
||||
"input": {"query": "DeepSeek-V4"},
|
||||
},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_00_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "web_search_result",
|
||||
"url": "https://api-docs.deepseek.com",
|
||||
"encrypted_content": "sd918tZXSvVp",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
# One round: the search answered inside the response, so nothing was sent back.
|
||||
assert len(client.calls) == 1
|
||||
|
||||
|
||||
async def test_search_only_round_is_not_an_empty_completion() -> None:
|
||||
"""A search turn with no text yet still produced payload.
|
||||
|
||||
A budget-capped or ``max_uses_exceeded`` search can end the turn on its
|
||||
blocks alone; calling that an empty completion failed a round the model had
|
||||
actually used.
|
||||
"""
|
||||
client = ScriptedMessagesClient(stream_events=[_search_stream_events(text=False)])
|
||||
events = [
|
||||
event
|
||||
async for event in run_chat_loop(
|
||||
cast("Any", client),
|
||||
[UserChatMessage(content="what is DeepSeek-V4?")],
|
||||
model="deepseek-flash",
|
||||
stream=True,
|
||||
)
|
||||
]
|
||||
assistant = next(event.message for event in events if isinstance(event, AssistantMessageEvent))
|
||||
assert assistant.content is None
|
||||
assert assistant.provider_blocks is not UNSET
|
||||
assert len(assistant.provider_blocks) == 3
|
||||
|
||||
|
||||
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")])
|
||||
|
||||
@@ -6,11 +6,14 @@ import pytest
|
||||
from plyngent.lmproto.anthropic import AnthropicClient
|
||||
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
||||
from plyngent.lmproto.anthropic.model import (
|
||||
AnthropicAssistantMessage,
|
||||
AnthropicContentBlockDelta,
|
||||
AnthropicMessageResponse,
|
||||
AnthropicMessagesParam,
|
||||
AnthropicMessageStop,
|
||||
AnthropicRawContentBlock,
|
||||
AnthropicResponseText,
|
||||
AnthropicTextContent,
|
||||
AnthropicThinkingConfig,
|
||||
AnthropicUsage,
|
||||
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}
|
||||
|
||||
|
||||
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:
|
||||
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ from __future__ import annotations
|
||||
from collections.abc import Sequence
|
||||
|
||||
import msgspec
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.lmproto.deepseek.openai_compat.model import ChatCompletionsParam as DeepseekChatCompletionsParam
|
||||
from plyngent.lmproto.openai_compatible.compat import (
|
||||
coerce_chat_completions_param,
|
||||
coerce_chat_completions_param_any,
|
||||
coerce_chat_messages,
|
||||
coerce_developer_messages_to_system,
|
||||
developer_to_system_message,
|
||||
)
|
||||
@@ -88,3 +90,32 @@ def test_generic_coercion_handles_deepseek_runtime_param() -> None:
|
||||
coerced = coerce_chat_completions_param_any(base)
|
||||
raw = msgspec.json.decode(msgspec.json.encode(coerced))
|
||||
assert raw["messages"][0]["role"] == "system"
|
||||
|
||||
|
||||
def test_coerce_chat_messages_drops_provider_blocks() -> None:
|
||||
"""Another surface's opaque blocks are not a chat-completions key."""
|
||||
messages: list[AnyChatMessage] = [
|
||||
UserChatMessage(content="hi"),
|
||||
AssistantChatMessage(
|
||||
content="done",
|
||||
reasoning_content="hmm",
|
||||
provider_blocks=[{"type": "thinking", "thinking": "hmm", "signature": "sig-1"}],
|
||||
),
|
||||
]
|
||||
coerced = coerce_chat_messages(messages)
|
||||
assistant = coerced[1]
|
||||
assert isinstance(assistant, AssistantChatMessage)
|
||||
assert assistant.provider_blocks is UNSET
|
||||
# The reasoning slot is the provider-neutral one and stays.
|
||||
assert assistant.reasoning_content == "hmm"
|
||||
raw = msgspec.json.decode(msgspec.json.encode(ChatCompletionsParam(model="m", messages=coerced)))
|
||||
assert "provider_blocks" not in raw["messages"][1]
|
||||
# The history the copy came from keeps them for the surface that made them.
|
||||
original = messages[1]
|
||||
assert isinstance(original, AssistantChatMessage)
|
||||
assert original.provider_blocks is not UNSET
|
||||
|
||||
|
||||
def test_coerce_chat_messages_leaves_plain_history_alone() -> None:
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi"), AssistantChatMessage(content="done")]
|
||||
assert coerce_chat_messages(messages) == messages
|
||||
|
||||
@@ -49,6 +49,24 @@ def test_edit_lineno_error_shows_read_window(workspace: object) -> None:
|
||||
assert "not read" in out
|
||||
assert "read lines 2-3" in out
|
||||
assert "margin" in out
|
||||
# Ready to copy: 1-based line 1 is offset 0, and n=4 clamps the limit.
|
||||
assert "read_file(path='j.txt', offset=0, limit=4, with_lineno=true)" in out
|
||||
|
||||
|
||||
def test_edit_lineno_error_suggests_the_read_file_call(workspace: object) -> None:
|
||||
"""Both rejections hand back a ready-to-copy call with a 0-based ``offset``."""
|
||||
del workspace
|
||||
_ = call_sync(write_file, "s.txt", "".join(f"line {i}\n" for i in range(1, 21)))
|
||||
_ = reset_lineno_tracker()
|
||||
_ = call_sync(read_file, "s.txt", offset=0, limit=10, with_lineno=True) # lines 1-10
|
||||
# Line 15 was never shown; the hint reads it back with a margin (line 12 → offset 11).
|
||||
out = call_sync(edit_lineno, "s.txt", 15, 15, "X\n")
|
||||
assert "read_file(path='s.txt', offset=11, limit=7, with_lineno=true)" in out
|
||||
# A stale read gets a fresh call for the same range after an external write.
|
||||
_ = call_sync(write_file, "s.txt", "changed\n")
|
||||
out = call_sync(edit_lineno, "s.txt", 1, 1, "X\n")
|
||||
assert "changed since it was read" in out
|
||||
assert "read_file(path='s.txt', offset=0, limit=1, with_lineno=true)" in out
|
||||
|
||||
|
||||
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
|
||||
@@ -84,6 +102,8 @@ def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
|
||||
_ = call_sync(read_file, "ap.txt", offset=0, limit=2, with_lineno=True)
|
||||
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
|
||||
assert "not read" in out and "3" in out
|
||||
# Appending needs the last line, so the suggested window ends at n.
|
||||
assert "read_file(path='ap.txt', offset=0, limit=3, with_lineno=true)" in out
|
||||
# Read the last line, then append succeeds.
|
||||
_ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True)
|
||||
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
|
||||
|
||||
Reference in New Issue
Block a user