mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 08:57:55 +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_compatible/config.py`** — `OpenAIConfig` (token + base URL).
|
||||||
- **`openai/model.py`** — OpenAI Responses API (`ResponsesCreateParam`, `Response`, function_call items, stream events).
|
- **`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`.
|
- **`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`.
|
- **`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 `[]`).
|
- **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.
|
- **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.
|
- **`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.
|
- **`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 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.
|
- **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).
|
- **`@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).
|
- **`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).
|
- **`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.
|
use `run_argv_batch` for ordered pipelines.
|
||||||
- `edit_replace`: fix match / `max_replaces`; `edit_lineno` edits only lines read via \
|
- `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(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 \
|
- `regex_files`: `pattern` is a Python `re` regex (not a glob, so `*.py` fails) and \
|
||||||
`path` is a literal file/dir (no glob expansion).
|
`path` is a literal file/dir (no glob expansion).
|
||||||
- Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
|
- 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.
|
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
|
||||||
- `wait` pauses; pressing Enter disturbs early (optional reason).
|
- `wait` pauses; pressing Enter disturbs early (optional reason).
|
||||||
- Todo stack: open items mean unfinished work; the stack does not auto-clear — \
|
- 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:
|
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:
|
if assistant.tool_calls is not UNSET and assistant.tool_calls:
|
||||||
return True
|
return True
|
||||||
if isinstance(assistant.content, str) and assistant.content.strip():
|
if isinstance(assistant.content, str) and assistant.content.strip():
|
||||||
return True
|
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
|
reasoning = assistant.reasoning_content
|
||||||
return bool(isinstance(reasoning, str) and reasoning.strip())
|
return bool(isinstance(reasoning, str) and reasoning.strip())
|
||||||
|
|
||||||
@@ -283,6 +288,23 @@ def _take_reasoning_delta(
|
|||||||
return [content], [ReasoningDeltaEvent(content=fresh)] if fresh else []
|
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(
|
async def _stream_round(
|
||||||
client: AnyLLMClient,
|
client: AnyLLMClient,
|
||||||
param: ChatCompletionsParam,
|
param: ChatCompletionsParam,
|
||||||
@@ -306,6 +328,7 @@ async def _stream_round(
|
|||||||
)
|
)
|
||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
reasoning_parts: list[str] = []
|
reasoning_parts: list[str] = []
|
||||||
|
provider_blocks: list[dict[str, Any]] = []
|
||||||
tool_deltas: list[StreamToolCallDelta] = []
|
tool_deltas: list[StreamToolCallDelta] = []
|
||||||
last_api_usage: object = UNSET
|
last_api_usage: object = UNSET
|
||||||
finish_reason: str | None = None
|
finish_reason: str | None = None
|
||||||
@@ -332,8 +355,7 @@ async def _stream_round(
|
|||||||
if isinstance(delta.content, str) and delta.content:
|
if isinstance(delta.content, str) and delta.content:
|
||||||
content_parts.append(delta.content)
|
content_parts.append(delta.content)
|
||||||
yield TextDeltaEvent(content=delta.content)
|
yield TextDeltaEvent(content=delta.content)
|
||||||
if delta.tool_calls is not UNSET and delta.tool_calls:
|
_accumulate_deltas(delta, tool_deltas=tool_deltas, provider_blocks=provider_blocks)
|
||||||
tool_deltas.extend(delta.tool_calls)
|
|
||||||
|
|
||||||
full_content = "".join(content_parts)
|
full_content = "".join(content_parts)
|
||||||
full_reasoning = "".join(reasoning_parts)
|
full_reasoning = "".join(reasoning_parts)
|
||||||
@@ -347,6 +369,7 @@ async def _stream_round(
|
|||||||
content=full_content or None,
|
content=full_content or None,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
reasoning_content=full_reasoning or UNSET,
|
reasoning_content=full_reasoning or UNSET,
|
||||||
|
provider_blocks=provider_blocks or UNSET,
|
||||||
)
|
)
|
||||||
# Usage-only final chunks leave choices empty; a non-empty usage after
|
# Usage-only final chunks leave choices empty; a non-empty usage after
|
||||||
# content still is not a finish_reason. Prefer explicit finish_reason.
|
# content still is not a finish_reason. Prefer explicit finish_reason.
|
||||||
|
|||||||
@@ -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,19 @@ 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. 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:
|
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 +233,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 +299,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ enter it.
|
|||||||
|
|
||||||
from __future__ import annotations
|
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 (
|
from plyngent.agent.messages_bridge import (
|
||||||
anthropic_response_to_chat_completion,
|
anthropic_response_to_chat_completion,
|
||||||
@@ -18,6 +19,8 @@ from plyngent.agent.messages_bridge import (
|
|||||||
)
|
)
|
||||||
from plyngent.agent.stream_chunks import (
|
from plyngent.agent.stream_chunks import (
|
||||||
finish_reason_chunk,
|
finish_reason_chunk,
|
||||||
|
provider_blocks_chunk,
|
||||||
|
reasoning_delta_chunk,
|
||||||
text_delta_chunk,
|
text_delta_chunk,
|
||||||
tool_call_delta_chunk,
|
tool_call_delta_chunk,
|
||||||
usage_chunk,
|
usage_chunk,
|
||||||
@@ -25,6 +28,7 @@ from plyngent.agent.stream_chunks import (
|
|||||||
from plyngent.lmproto.anthropic.model import (
|
from plyngent.lmproto.anthropic.model import (
|
||||||
AnthropicContentBlockDelta,
|
AnthropicContentBlockDelta,
|
||||||
AnthropicContentBlockStart,
|
AnthropicContentBlockStart,
|
||||||
|
AnthropicContentBlockStop,
|
||||||
AnthropicErrorEvent,
|
AnthropicErrorEvent,
|
||||||
AnthropicMessageDelta,
|
AnthropicMessageDelta,
|
||||||
AnthropicMessageStart,
|
AnthropicMessageStart,
|
||||||
@@ -65,6 +69,92 @@ class _ToolBlockState:
|
|||||||
self.tool_index = tool_index
|
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]:
|
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.
|
"""Merge ``message_start`` usage with the later ``message_delta`` usage.
|
||||||
|
|
||||||
@@ -99,6 +189,7 @@ class _StreamState:
|
|||||||
model: str
|
model: str
|
||||||
tool_blocks: dict[int, _ToolBlockState]
|
tool_blocks: dict[int, _ToolBlockState]
|
||||||
block_types: dict[int, str]
|
block_types: dict[int, str]
|
||||||
|
provider_blocks: dict[int, _ProviderBlockState]
|
||||||
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
|
||||||
@@ -109,6 +200,7 @@ class _StreamState:
|
|||||||
self.model = model
|
self.model = model
|
||||||
self.tool_blocks = {}
|
self.tool_blocks = {}
|
||||||
self.block_types = {}
|
self.block_types = {}
|
||||||
|
self.provider_blocks = {}
|
||||||
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
|
||||||
@@ -140,13 +232,17 @@ def _handle_block_start(
|
|||||||
) -> list[ChatCompletionChunk]:
|
) -> list[ChatCompletionChunk]:
|
||||||
"""Record the block's type; only ``tool_use`` becomes a local tool call.
|
"""Record the block's type; only ``tool_use`` becomes a local tool call.
|
||||||
|
|
||||||
``server_tool_use`` (a search the model's backend runs), its
|
A surface-owned block (``server_tool_use``, its ``web_search_tool_result``,
|
||||||
``web_search_tool_result``, and ``thinking`` stream here too — recording
|
``thinking``) is rebuilt from here on so the round can hand it back (see
|
||||||
their type keeps the argument fragments that follow from being mistaken for
|
``AssistantChatMessage.provider_blocks``); recording its type also keeps the
|
||||||
a local call's arguments (see ``_chunks_for_content_delta``).
|
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
|
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":
|
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}"
|
||||||
@@ -172,6 +268,17 @@ def _handle_message_delta(event: AnthropicMessageDelta, state: _StreamState) ->
|
|||||||
state.last_usage = _merge_usage(state.last_usage, usage)
|
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]:
|
def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[ChatCompletionChunk]:
|
||||||
"""Convert one Anthropic stream event into zero or more chat chunks."""
|
"""Convert one Anthropic stream event into zero or more chat chunks."""
|
||||||
if isinstance(event, AnthropicErrorEvent):
|
if isinstance(event, AnthropicErrorEvent):
|
||||||
@@ -188,6 +295,9 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
|
|||||||
if isinstance(event, AnthropicContentBlockStart):
|
if isinstance(event, AnthropicContentBlockStart):
|
||||||
return _handle_block_start(event, state)
|
return _handle_block_start(event, state)
|
||||||
|
|
||||||
|
if isinstance(event, AnthropicContentBlockStop):
|
||||||
|
return _chunks_for_block_stop(event, state)
|
||||||
|
|
||||||
if isinstance(event, AnthropicContentBlockDelta):
|
if isinstance(event, AnthropicContentBlockDelta):
|
||||||
return _chunks_for_content_delta(event, state)
|
return _chunks_for_content_delta(event, state)
|
||||||
|
|
||||||
@@ -201,11 +311,31 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
|
|||||||
return []
|
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(
|
def _chunks_for_content_delta(
|
||||||
event: AnthropicContentBlockDelta,
|
event: AnthropicContentBlockDelta,
|
||||||
state: _StreamState,
|
state: _StreamState,
|
||||||
) -> list[ChatCompletionChunk]:
|
) -> list[ChatCompletionChunk]:
|
||||||
delta: AnthropicRawContentBlock = event.delta
|
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:
|
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)]
|
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(
|
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",
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ raise `max_replaces` or narrow `old_string`.
|
|||||||
re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete, \
|
re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete, \
|
||||||
since line numbers go stale.
|
since line numbers go stale.
|
||||||
- `read_file` results start with a 1-based line range `L{begin}-{end}` (`offset` \
|
- `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 \
|
- `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` \
|
(`*.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 \
|
(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.
|
- Prefer `vcs_*` for status/diff/log/branch when enough.
|
||||||
|
|
||||||
### Network
|
### 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; \
|
- 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.
|
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).
|
- 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"):
|
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"):
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
from msgspec import UNSET
|
||||||
|
|
||||||
from .model import DeveloperChatMessage, SystemChatMessage
|
from .model import AssistantChatMessage, DeveloperChatMessage, SystemChatMessage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
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)
|
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:
|
def coerce_chat_completions_param(param: ChatCompletionsParam) -> ChatCompletionsParam:
|
||||||
"""Normalize a chat-completions request for broad provider compatibility."""
|
"""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:
|
if messages == param.messages:
|
||||||
return param
|
return param
|
||||||
return msgspec.structs.replace(param, messages=messages)
|
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):
|
if not isinstance(messages_obj, list):
|
||||||
return param
|
return param
|
||||||
messages = cast("list[AnyChatMessage]", messages_obj)
|
messages = cast("list[AnyChatMessage]", messages_obj)
|
||||||
coerced = coerce_developer_messages_to_system(messages)
|
coerced = coerce_chat_messages(messages)
|
||||||
if coerced == messages:
|
if coerced == messages:
|
||||||
return param
|
return param
|
||||||
return msgspec.structs.replace(param, messages=coerced)
|
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
|
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):
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ if TYPE_CHECKING:
|
|||||||
# How many unread line numbers to preview in the error message.
|
# How many unread line numbers to preview in the error message.
|
||||||
_UNREAD_PREVIEW = 8
|
_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:
|
def _detect_newline(lines: list[str]) -> str:
|
||||||
for sample in lines:
|
for sample in lines:
|
||||||
@@ -68,6 +71,19 @@ def _unread_lines_in_range(
|
|||||||
return sorted(set(range(start_line, end_line + 1)) - read_lines)
|
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:
|
def _append_after(target: Path, path: str, text: str, lines: list[str], new_content: str) -> str:
|
||||||
n = len(lines)
|
n = len(lines)
|
||||||
newline = _detect_newline(lines[-1:] if lines else [])
|
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:
|
if read_lines:
|
||||||
window = f" (this turn read lines {min(read_lines)}-{max(read_lines)})"
|
window = f" (this turn read lines {min(read_lines)}-{max(read_lines)})"
|
||||||
return (
|
return (
|
||||||
f"error: lines {head} not read{window}; call read_file(path, with_lineno=true) "
|
f"error: lines {head} not read{window}; re-read with "
|
||||||
"first — read with a small margin around the lines you plan to edit"
|
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))
|
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:
|
if current_mtime is not None and current_mtime != recorded_mtime:
|
||||||
return (
|
return (
|
||||||
f"error: {path} changed since it was read (line numbers may be stale); "
|
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:
|
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.
|
"""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.
|
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`` sets User-Agent when provided and takes precedence over a
|
||||||
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
|
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
|
||||||
When both omit UA, a small default is used.
|
When both omit UA, a small default is used.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from msgspec import UNSET
|
|||||||
|
|
||||||
from plyngent.agent import (
|
from plyngent.agent import (
|
||||||
AssistantMessageEvent,
|
AssistantMessageEvent,
|
||||||
|
ReasoningDeltaEvent,
|
||||||
TextDeltaEvent,
|
TextDeltaEvent,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolRegistry,
|
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)
|
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."""
|
"""The stream a DeepSeek search turn sends: thinking, search, results, text."""
|
||||||
return [
|
events: list[AnthropicStreamEvent] = [
|
||||||
AnthropicMessageStart(
|
AnthropicMessageStart(
|
||||||
message=AnthropicMessageResponse(
|
message=AnthropicMessageResponse(
|
||||||
id="msg_s",
|
id="msg_s",
|
||||||
@@ -407,11 +408,16 @@ def _search_stream_events() -> list[AnthropicStreamEvent]:
|
|||||||
usage=AnthropicUsage(input_tokens=164, output_tokens=0),
|
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(
|
AnthropicContentBlockDelta(
|
||||||
index=0,
|
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),
|
AnthropicContentBlockStop(index=0),
|
||||||
AnthropicContentBlockStart(
|
AnthropicContentBlockStart(
|
||||||
index=1,
|
index=1,
|
||||||
@@ -430,23 +436,39 @@ def _search_stream_events() -> list[AnthropicStreamEvent]:
|
|||||||
index=2,
|
index=2,
|
||||||
content_block=AnthropicRawContentBlock(
|
content_block=AnthropicRawContentBlock(
|
||||||
type="web_search_tool_result",
|
type="web_search_tool_result",
|
||||||
id="call_00_1",
|
tool_use_id="call_00_1",
|
||||||
input={"content": [{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}]},
|
content=[
|
||||||
|
{
|
||||||
|
"type": "web_search_result",
|
||||||
|
"url": "https://api-docs.deepseek.com",
|
||||||
|
"encrypted_content": "sd918tZXSvVp",
|
||||||
|
}
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnthropicContentBlockStop(index=2),
|
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:
|
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, ToolCallEvent) for event in events)
|
||||||
assert not any(isinstance(event, ToolResultEvent) 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)
|
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.
|
# One round: the search answered inside the response, so nothing was sent back.
|
||||||
assert len(client.calls) == 1
|
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:
|
async def test_dispatch_messages_sends_provider_tools_as_server_tools() -> None:
|
||||||
"""Hosted tools reach the Anthropic request in that surface's own shape."""
|
"""Hosted tools reach the Anthropic request in that surface's own shape."""
|
||||||
client = ScriptedMessagesClient(non_stream=[_message_response(text="ok")])
|
client = ScriptedMessagesClient(non_stream=[_message_response(text="ok")])
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ from __future__ import annotations
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
from msgspec import UNSET
|
||||||
|
|
||||||
from plyngent.lmproto.deepseek.openai_compat.model import ChatCompletionsParam as DeepseekChatCompletionsParam
|
from plyngent.lmproto.deepseek.openai_compat.model import ChatCompletionsParam as DeepseekChatCompletionsParam
|
||||||
from plyngent.lmproto.openai_compatible.compat import (
|
from plyngent.lmproto.openai_compatible.compat import (
|
||||||
coerce_chat_completions_param,
|
coerce_chat_completions_param,
|
||||||
coerce_chat_completions_param_any,
|
coerce_chat_completions_param_any,
|
||||||
|
coerce_chat_messages,
|
||||||
coerce_developer_messages_to_system,
|
coerce_developer_messages_to_system,
|
||||||
developer_to_system_message,
|
developer_to_system_message,
|
||||||
)
|
)
|
||||||
@@ -88,3 +90,32 @@ def test_generic_coercion_handles_deepseek_runtime_param() -> None:
|
|||||||
coerced = coerce_chat_completions_param_any(base)
|
coerced = coerce_chat_completions_param_any(base)
|
||||||
raw = msgspec.json.decode(msgspec.json.encode(coerced))
|
raw = msgspec.json.decode(msgspec.json.encode(coerced))
|
||||||
assert raw["messages"][0]["role"] == "system"
|
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 "not read" in out
|
||||||
assert "read lines 2-3" in out
|
assert "read lines 2-3" in out
|
||||||
assert "margin" 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:
|
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)
|
_ = call_sync(read_file, "ap.txt", offset=0, limit=2, with_lineno=True)
|
||||||
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
|
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
|
||||||
assert "not read" in out and "3" in out
|
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.
|
# Read the last line, then append succeeds.
|
||||||
_ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True)
|
_ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True)
|
||||||
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
|
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user