50 Commits

Author SHA1 Message Date
NCBM c1dfdc8478 release: plyngent 0.3.0 2026-08-04 00:06:18 +08:00
NCBM 3449f3a77d lint: drop mypy-style type ignore comments 2026-08-03 23:38:32 +08:00
NCBM a834984bcc core/memory: merge session schema migrations by version 2026-08-01 02:47:08 +08:00
NCBM 8954d855d5 docs: document DeepSeek Anthropic convention support 2026-08-01 02:32:45 +08:00
NCBM a198fa9d41 runtime: route DeepSeek convention=anthropic to Anthropic client 2026-08-01 02:31:21 +08:00
NCBM 439c24a1ce lmproto: add DeepSeek Anthropic client 2026-08-01 02:30:19 +08:00
NCBM 6e4d229f6f config: default deepseek anthropic convention url 2026-08-01 02:27:23 +08:00
NCBM f6072ce2a6 core/agent: interleave responses tool outputs after their calls 2026-08-01 02:16:48 +08:00
NCBM 6932937f87 docs: document DeepSeek Responses via convention field 2026-08-01 02:05:53 +08:00
NCBM 4b7cc93917 runtime: route DeepSeek convention=responses to Responses client 2026-08-01 02:03:52 +08:00
NCBM bfafe74a6f core/agent: handle incomplete/failed Responses stream terminals 2026-08-01 02:03:02 +08:00
NCBM db6d2a83f4 lmproto: add DeepSeek Responses client 2026-08-01 02:01:06 +08:00
NCBM 8ad33af652 config: add typed DeepSeek convention field for API surface 2026-08-01 01:59:39 +08:00
NCBM a877dba8c1 cli/repl: keep second Ctrl+C inside the REPL 2026-08-01 01:45:10 +08:00
NCBM 3fe1c4cf91 core/agent: add model-friendly hint to truncated tool results 2026-08-01 01:42:17 +08:00
NCBM 49a3ef0004 docs: describe read-line edit guard in tool playbook 2026-08-01 01:25:22 +08:00
NCBM 19ac4533d4 core/tools: invalidate lineno read state on file mutation 2026-08-01 01:22:30 +08:00
NCBM 56ac8d17d7 core/tools: restrict edit_lineno to lines read with line numbers 2026-08-01 01:21:00 +08:00
NCBM 0cb7a29f21 core/agent: track per-line lineno read state 2026-08-01 01:20:51 +08:00
NCBM 1d4e48f6f3 core/agent: normalize provider usage structs in token_usage_from_api 2026-08-01 01:09:12 +08:00
NCBM 626012e92f core/agent: unify provider finish_reason mapping 2026-08-01 01:05:26 +08:00
NCBM 5198a8da20 core/agent: align missing-terminal contract in messages dispatch 2026-08-01 01:05:17 +08:00
NCBM e29e66dbc7 core/agent: surface openai responses stream error events 2026-08-01 01:05:09 +08:00
NCBM 9aea91e77c core/agent: share stream chunk builders across responses and messages bridges 2026-08-01 00:48:44 +08:00
NCBM 06f1b386a0 test/lmproto: cover anthropic messages client HTTP round-trips 2026-08-01 00:41:59 +08:00
NCBM 556eca66db docs: document openai responses and anthropic messages wiring 2026-08-01 00:34:48 +08:00
NCBM 44a5f047ba core/runtime: plumb anthropic HTTP timeout 2026-08-01 00:34:41 +08:00
NCBM 3a671f73ea core/agent: wire anthropic messages bridge and dispatch 2026-08-01 00:34:32 +08:00
NCBM de7cae83e4 core/agent: restore OpenAI responses dispatch and provider_tools 2026-08-01 00:34:15 +08:00
NCBM ad58d6cdc6 lint: drop dead code that blocks basedpyright (unused helper and import) 2026-08-01 00:33:25 +08:00
NCBM ef3594ad62 core/tools: change ask_user_choice and ask_user_form params from JSON strings to typed lists 2026-07-29 01:42:00 +08:00
NCBM f5b566b371 core/tools: add per-turn guard requiring lineno read before edit_lineno 2026-07-29 01:40:31 +08:00
NCBM 39ecd55ddd cli: fix limit prompt redundancy and line-break 2026-07-29 01:38:04 +08:00
NCBM 60f9c9b56d core/agent: danger classification overrides YOLO tag in confirm pipeline 2026-07-29 01:37:17 +08:00
NCBM 904ac3d982 core/agent: remove turn-start todo nag -- nag only fires after complete turn 2026-07-29 01:07:59 +08:00
NCBM f60fd876c6 test: update for kind-based dispatch and remove ResponsesChatClient references 2026-07-29 00:46:08 +08:00
NCBM f0c27bf366 core: wire kind-based dispatch through agent, runtime, and state 2026-07-29 00:40:23 +08:00
NCBM 4562eaa1cb core/agent: remove ChatClient protocol and ResponsesChatClient wrapper 2026-07-29 00:36:19 +08:00
NCBM 6eaadd6ebf core/lmproto: add kind property to all clients + AnyLLMClient type 2026-07-29 00:34:42 +08:00
NCBM 49e878fe3a core/lmproto: add Anthropic messages models, config, and client 2026-07-29 00:33:58 +08:00
NCBM 200bde0bfb core/todo-nag: inject reasoning_content on synthetic assistant messages
DeepSeek's reasoning API enforces: if an assistant with tool_calls sits
between two user messages, its reasoning_content must be passed back.
The synthetic todo nag pair creates an AssistantChatMessage with tool_calls
but no reasoning_content, which triggered a 400 error:
"The reasoning_content in the thinking mode must be passed back to the API."

Fix:
1. _append_synthetic_todo_list now sets reasoning_content="" so NEW pairs
   carry the field.
2. refresh_synthetic_todo_nags runs _inject_reasoning_content_for_synthetic
   to fix EXISTING pairs (in DB) even if reasoning_content was UNSET.
3. The earlier _inject_thinking change is reverted in the next commit since
   the real root cause was the missing reasoning_content on forged tool_call
   messages, not the thinking parameter itself.
2026-07-29 00:03:04 +08:00
NCBM 0ce9ed84a0 core/agent: suppress end-of-turn todo nag on retry when turn-start pair survived 2026-07-28 23:52:06 +08:00
NCBM a83c67ba6d test/agent: cover synthetic_tool todo nag retry detection
Adds 7 unit tests for _synthetic_todo_pair_after and committed_prefix_end
with synthetic todo nag pairs: survival across rollback, detection at
correct index, immunity to real tool calls, and multiple rollbacks.

These directly verify the guard that prevents duplicate turn-start
nag injection on /retry when a synthetic pair from a previous failed
attempt survived rollback (committed tool batch).
2026-07-28 23:45:27 +08:00
NCBM 3a5c715e8f core/deepseek: inject thinking parameter to satisfy API reasoning mode requirement
DeepSeek's reasoning model requires the `thinking` parameter to be
explicitly set in every request (default is enabled). The agent loop
constructs the base ChatCompletionsParam which lacks this field, so the
DeepseekOpenAIClient now injects `thinking: {type: "enabled"}` into the
JSON request body after msgspec encoding.

Fixes the HTTP 400 error:
"The `reasoning_content` in the thinking mode must be passed back to the API."
which occurred when a cancelled turn was retried and the request history
contained assistant messages with reasoning_content but no thinking param.
2026-07-28 23:32:02 +08:00
NCBM fc98b080e1 core/agent: skip duplicate synthetic todo nag injection on retry 2026-07-28 22:44:21 +08:00
NCBM 9600d2f757 core/lmproto: coerce developer role for chat completions 2026-07-28 22:16:36 +08:00
NCBM c7ee9b0063 test/config: cover model-level preset routing 2026-07-28 21:45:59 +08:00
NCBM 3b72698e14 core/runtime: route clients by model-level preset 2026-07-28 21:44:12 +08:00
NCBM 68025ca626 core/config: model-level preset and url overrides 2026-07-28 21:42:48 +08:00
NCBM 0ca92dfd7e core/config: networking ssrf_assume_public_cidrs for fetch Fake-IP 2026-07-24 20:20:30 +08:00
90 changed files with 4970 additions and 622 deletions
+18 -12
View File
@@ -39,23 +39,28 @@ All protocol models use `msgspec.Struct` — not dataclasses, not Pydantic. Opti
```
lmproto/openai_compatible/ ← Base: model, config, client
lmproto/openai/ ← OpenAI platform: Responses + chat (extends base)
lmproto/anthropic/ ← Anthropic Messages: model, config, client
lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields
lmproto/deepseek/responses/ ← DeepSeek Responses client (extends OpenAI Responses client; SSE stops at terminal events, no `[DONE]`)
lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends AnthropicClient; no `GET /models`)
```
- **`openai_compatible/model.py`** — tagged chat messages (`SystemChatMessage`, `UserChatMessage`, …), tools, request/response, streaming chunks.
- **`openai_compatible/client.py`** — `BaseOpenAIClient` / `OpenAICompatibleClient` via `niquests` async + SSE; `chat_completions`, `models` only (`OpenAIClient` is a compat alias).
- **`openai_compatible/client.py`** — `BaseOpenAIClient` / `OpenAICompatibleClient` via `niquests` async + SSE; `chat_completions`, `models` only.
- **`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`: chat completions + `responses` / `get_response` / `delete_response`.
- **DeepSeek** — `DeepseekOpenAIClient`; models add `reasoning_content`, `prefix`, `ThinkingOptions`. Config default model ids: `deepseek-v4-flash`, `deepseek-v4-pro`.
- **`openai/client.py`** — platform `OpenAIClient` (`kind="responses"`): chat completions + `responses` / `get_response` / `delete_response`.
- **`anthropic/model.py`** — Anthropic Messages request/response + SSE event models.
- **`anthropic/client.py`** — `AnthropicClient` (`kind="messages"`): `POST /messages` + `GET /models`.
- **DeepSeek** — `DeepseekOpenAIClient` (`kind="chat_completions"`); models add `reasoning_content`, `prefix`, `ThinkingOptions`. Config default model ids: `deepseek-v4-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 `[]`).
### Config (`config/`)
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read).
TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). `DeepseekProvider.convention` (Literal: `""`/`"openai"`/`"anthropic"`/`"responses"`) selects the DeepSeek API surface — `"responses"` → OpenAI Responses, `"anthropic"` → Anthropic Messages on `https://api.deepseek.com/anthropic` (default url follows the convention); a per-model `ModelConfig.convention` overrides it (empty = inherit).
### Runtime (`runtime/`)
`create_client(provider)` maps config `Provider` → protocol client. `provider_to_openai_config` normalizes `timeout` via `normalize_http_timeout` into `OpenAIConfig.timeout` for `niquests.AsyncSession`. OpenAI → `lmproto.openai.OpenAIClient` (Responses-capable); openai-compatible / deepseek(openai convention) → chat-completions clients; anthropic and deepseek anthropic convention raise `ProviderNotSupportedError`.
`create_client(provider)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply). `normalize_http_timeout` feeds OpenAI-compatible configs and Anthropic. Mapping: `openai` → `OpenAIClient` (agent uses Responses), `openai-compatible` / `deepseek` → chat-completions clients (deepseek with effective `convention = "responses"` → `DeepseekResponsesClient`, `"anthropic"` → `DeepseekAnthropicClient`), `anthropic` → `AnthropicClient`.
### Memory (`memory/`)
@@ -63,23 +68,24 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
### Agent (`agent/`)
- **`ChatClient`** Protocol for `chat_completions` (agent history stays chat-shaped).
- **OpenAI Responses integration**: `ResponsesChatClient` adapts platform `OpenAIClient.responses` to `ChatClient`; selected automatically for `OpenAIProvider` in `create_client`. Compat/DeepSeek stay on chat completions.
- **Provider-side tools**: `OpenAIProvider.provider_tools` (list of dicts; default `[{type="web_search"}]` when omitted; `[]` disables) merged into Responses `tools` alongside local function tools; never executed by `ToolRegistry`.
- **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; tool calls + usage on `response.completed`.
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments.
- **Provider-side tools**: `OpenAIProvider.provider_tools` (list of dicts; default `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses only; never executed by `ToolRegistry`.
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name.
- **`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`.
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
- **`/compact`**: soft-compact tool dumps → model summary (no tools) → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
- **`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).
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens).
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
### Tools (`tools/`)
Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under primary root **or** temporary allowlist roots; path substring denylist; command basename denylist (CLI: timed human allow override, default deny on timeout; **not** skipped by YOLO); clearer deny messages.
- **`file`**: `read_file` (`with_lineno`), `write_file`, `listdir`, `tree` (VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind).
- **`file`**: `read_file` (`with_lineno`), `write_file`, `listdir`, `tree` (VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace` (`max_replaces`, reports remaining matches), `edit_lineno` (1-based range; only lines read via `with_lineno`; read state resets on any file write/edit/copy/move/delete), `copy_path` / `move_path` / `delete_path`, `new_temporary_workspace` (system temp allowlist; cleanup on chat exit; no session rebind).
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); `run_command_batch` (serial steps, `pipe_out` on provider, `mix_stderr`, `stop_on_error`); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `ask_into_pty` (human→PTY only; `secret` no-echo; answer never in tool result) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (**CSI sanitized** so host TTY is never reset); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); secrets via `ask_into_pty` (not `write_pty` data); session limit/idle TTL/output budget; close terminate→kill.
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
+30 -3
View File
@@ -157,7 +157,34 @@ Per-provider **`timeout`** is passed to the HTTP session for chat/completions, R
Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md).
Supported provider presets today: `openai` (default if `preset` is omitted; default models `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` when `models` is omitted), `openai-compatible`, `deepseek` (OpenAI convention; default models `deepseek-v4-flash` and `deepseek-v4-pro` if `models` is omitted). Anthropic presets are modeled in config but not wired in the runtime client yet.
Supported provider presets today:
| Preset | API used by the agent | Notes |
|--------|------------------------|--------|
| `openai` (default if `preset` omitted) | OpenAI **Responses** (`POST /responses`) | Default models `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` when `models` is omitted; optional `provider_tools` (default `web_search`) |
| `openai-compatible` | Chat Completions | Generic hosts (vLLM, LiteLLM, proxies); requires `url` |
| `deepseek` | Chat Completions (DeepSeek) | Default models `deepseek-v4-flash` / `deepseek-v4-pro` when `models` is omitted; set `convention = "responses"` (provider or per-model) to use the OpenAI **Responses** API (`POST /responses`, currently only `deepseek-v4-flash`), or `convention = "anthropic"` to use the Anthropic **Messages** API on `https://api.deepseek.com/anthropic` |
| `anthropic` | Anthropic **Messages** (`POST /messages`) | Native tools/streaming; set `access_key_or_token` (API key) |
Model-level `preset` / `url` overrides on a catalog entry can route a single provider name to a different API (e.g. gateway + Anthropic model). DeepSeek models may also carry a per-model `convention` (empty = inherit the provider-level one), e.g. serve `deepseek-v4-flash` over Responses while `deepseek-v4-pro` stays Chat Completions:
```toml
[providers.deepseek]
preset = "deepseek"
access_key_or_token = "sk-..."
[providers.deepseek.models]
"deepseek-v4-flash" = { text = true, convention = "responses" }
"deepseek-v4-pro" = { text = true }
```
DeepSeek's Anthropic convention (`convention = "anthropic"`) points at
`https://api.deepseek.com/anthropic` by default and is fully supported by the
agent's Anthropic Messages path (tools, streaming, usage). DeepSeek maps
`claude-opus*` model ids to `deepseek-v4-pro` and `claude-haiku*` /
`claude-sonnet*` (and unknown ids) to `deepseek-v4-flash` server-side; real
`deepseek-v4-*` ids pass through as-is. `GET /models` is not documented on that
base, so model selection is config-driven.
If `[database]` is omitted (or SQLite `url` is unset/empty), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux). Set `url = ":memory:"` for a true in-memory SQLite (CLI warns; no file; useful for tests).
@@ -274,8 +301,8 @@ plyngent --log-level INFO chat ...
See [doc/architecture.md](doc/architecture.md) and [CLAUDE.md](CLAUDE.md) for developers.
- **`lmproto/`** — OpenAI-compatible (+ DeepSeek) msgspec models and async SSE clients
- **`agent/`** — tool loop, streaming, usage, compact
- **`lmproto/`** — OpenAI-compatible, OpenAI Responses, Anthropic Messages, DeepSeek msgspec models and async SSE clients
- **`agent/`** — kind-based tool loop (`chat_completions` / `responses` / `messages`), streaming, usage, compact
- **`memory/`** — async SQLAlchemy sessions/messages
- **`tools/`** — workspace tools
- **`cli/`** — Click entry + slash registry (`awaitlet` bridges sync Click to async work)
+14 -8
View File
@@ -8,19 +8,25 @@
- config: Configurations for specified providers.
- client: Clients for accessing existent specified services.
- server: Servers for accepting other clients.
- openai
- openai_compatible
- anthropic
- ollama
- openai_compatible: Chat Completions base (``kind=chat_completions``).
- openai: Platform Responses + chat (``kind=responses``).
- anthropic: Messages API (``kind=messages``).
- deepseek
- openai_compat
- anthropic
- openai_compat (``kind=chat_completions``)
- responses (``kind=responses``; DeepSeek Responses API)
- anthropic (``kind=messages``; DeepSeek Anthropic-compatible API on
``https://api.deepseek.com/anthropic``)
- utils: Common utilities for code architecture.
- components: Utilities for class composition.
- memory: Storage controlling for sessions and messages.
- router: Multi-source capability routing (Phase H; not implemented).
- config: Plyngent configuration center (TOML), including ``[plugins]``.
- agent: Tool loop, streaming, usage, compact; `@tool` / tags / registry.
- config: Plyngent configuration center (TOML), including ``[plugins]`` and
``[networking]`` (e.g. fetch SSRF Fake-IP CIDR exemptions). Model entries
may override provider ``preset`` / ``url`` for mixed API routing.
- runtime: ``create_client`` maps effective provider preset → protocol client.
- agent: Kind-based tool loop, streaming, usage, compact; bridges:
``responses_bridge``/``responses_dispatch``, ``messages_bridge``/``messages_dispatch``;
``@tool`` / tags / registry. History stays chat-completions-shaped.
- tools: Workspace file/process/VCS/chat/todo/net tools; catalog; plugins;
instance/session context and views.
- prompting: Shared ask/choose/form for CLI and tools.
+36 -4
View File
@@ -19,6 +19,13 @@
# enable = ["*"]
# disable = ["legacy"]
# Host network / SSRF policy for tools such as ``fetch``.
# Clash TUN + Fake-IP often resolves public names to 198.18.0.0/15; list that
# pool so fetch does not treat every site as private/loopback.
# Metadata (169.254.169.254) stays forbidden even if listed.
# [networking]
# ssrf_assume_public_cidrs = ["198.18.0.0/15"]
[agent]
# Persona (omit → built-in coding-agent line). tool_directives is the tool playbook.
# Override with multi-line literals (prefer ''' so nested " is fine):
@@ -80,15 +87,40 @@ access_key_or_token = "sk-replace-me"
#
# [providers.openai.models]
# "gpt-4o-mini" = { text = true }
# # Model-level preset/url overrides let one provider route models via different APIs.
# # `preset = "openai"` means /responses; `openai-compatible` means /chat/completions;
# # `anthropic` means native /messages.
# # "claude-sonnet" = { text = true, preset = "anthropic", url = "https://api.anthropic.com/v1" }
# --- DeepSeek (OpenAI-compatible convention) ---
# --- Anthropic (Messages API) ---
# [providers.anthropic]
# preset = "anthropic"
# access_key_or_token = "sk-ant-replace-me"
# # url defaults to https://api.anthropic.com/v1 when empty
# timeout = { connect = 10, read = 600 }
#
# [providers.anthropic.models]
# "claude-sonnet-4-20250514" = { text = true }
# "claude-opus-4-20250514" = { text = true }
# --- DeepSeek (OpenAI-compatible / Responses / Anthropic conventions) ---
# Default models if [providers.*.models] is omitted: deepseek-v4-flash, deepseek-v4-pro.
# [providers.deepseek]
# preset = "deepseek"
# access_key_or_token = "sk-replace-me"
# # url defaults to https://api.deepseek.com when empty
# extras = { convention = "openai" }
# # url defaults by convention: https://api.deepseek.com (chat/responses),
# # https://api.deepseek.com/anthropic (anthropic). Set explicitly to override.
# # Convention selects the API surface:
# # "" / "openai" → OpenAI-compatible chat completions (default)
# # "responses" → OpenAI Responses API (POST /responses; today only
# # deepseek-v4-flash supports it)
# # "anthropic" → Anthropic Messages API (POST /messages on
# # https://api.deepseek.com/anthropic; claude-* model ids
# # map server-side to deepseek-v4-*)
# convention = "responses"
# # Legacy location (parsed but ignored; prefer the typed field above):
# # extras = { convention = "openai" }
#
# [providers.deepseek.models]
# "deepseek-v4-flash" = { text = true }
# "deepseek-v4-flash" = { text = true, convention = "responses" }
# "deepseek-v4-pro" = { text = true }
Generated
+3 -3
View File
@@ -278,7 +278,7 @@ files = [
[[package]]
name = "niquests"
version = "3.20.1"
version = "3.21.0"
requires_python = ">=3.7"
summary = "Niquests is a simple, yet elegant, HTTP library. It is a drop-in replacement for Requests, which is under feature freeze."
groups = ["default"]
@@ -288,8 +288,8 @@ dependencies = [
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
]
files = [
{file = "niquests-3.20.1-py3-none-any.whl", hash = "sha256:71062880739c94d67aead1f8b7972baad3236b97a55a490493729789d1639fc4"},
{file = "niquests-3.20.1.tar.gz", hash = "sha256:55951812cf997963bfef8e4f797ff0460ca571e53ec184039b010e12152c0e41"},
{file = "niquests-3.21.0-py3-none-any.whl", hash = "sha256:dbea441b9e9d5d755e02caa2b57b94cdb97b23d754ead21b80a4ac18ef66805f"},
{file = "niquests-3.21.0.tar.gz", hash = "sha256:5b7d10a05f4c7ed08cede0af74f492ae7a8a5a71291833d029e23365fc3ea80a"},
]
[[package]]
+3 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "plyngent"
version = "0.2.0"
version = "0.3.0"
description = "LLM chat and agent toolkit"
authors = [
{ name = "HivertMoZara", email = "worldmozara@163.com" },
@@ -63,6 +63,8 @@ reportMissingParameterType = "none"
reportUnusedParameter = "none"
reportMissingImports = "none"
reportMissingTypeStubs = "none"
reportArgumentType = "none"
reportAttributeAccessIssue = "none"
[[tool.basedpyright.executionEnvironments]]
root = "scripts"
+6 -3
View File
@@ -1,5 +1,10 @@
from .chat import ChatAgent as ChatAgent
from .client import ChatClient as ChatClient
from .chat import get_lineno_read_files as get_lineno_read_files
from .chat import invalidate_lineno_read as invalidate_lineno_read
from .chat import lineno_read_lines as lineno_read_lines
from .chat import mark_lineno_read as mark_lineno_read
from .chat import reset_lineno_tracker as reset_lineno_tracker
from .chat import was_lineno_read as was_lineno_read
from .compact import build_compacted_seed_messages as build_compacted_seed_messages
from .compact import summarize_messages as summarize_messages
from .events import AgentEvent as AgentEvent
@@ -14,8 +19,6 @@ from .events import ToolResultEvent as ToolResultEvent
from .events import UsageEvent as UsageEvent
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
from .loop import run_chat_loop as run_chat_loop
from .responses_client import ResponsesChatClient as ResponsesChatClient
from .responses_client import wrap_openai_for_agent as wrap_openai_for_agent
from .tools import DangerClassifier as DangerClassifier
from .tools import ToolConfirmHook as ToolConfirmHook
from .tools import ToolDefinition as ToolDefinition
+20 -3
View File
@@ -28,13 +28,30 @@ DEFAULT_RECENT_TOOL_RESULTS = 12
DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4
def truncate_tool_result(text: str, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS) -> str:
"""Cap tool output so huge dumps do not flood model context."""
def truncate_tool_result(
text: str,
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
*,
hint: bool = True,
) -> str:
"""Cap tool output so huge dumps do not flood model context.
With ``hint=True`` the suffix tells the model the result was cut and that
it may ask the user to raise the config limit (``max_tool_result_chars``).
``hint=False`` keeps the terse marker for internal shrinks whose cap is not
a user-facing config knob (e.g. request-time compacting).
"""
if max_chars < 1:
return text
if len(text) <= max_chars:
return text
omitted = len(text) - max_chars
if hint:
return (
f"{text[:max_chars]}\n"
f"...[Truncated ({max_chars} chars max.; {omitted} chars omitted). "
f"You may ask for user to increase the limit via configuration file.]"
)
return f"{text[:max_chars]}\n...[truncated {omitted} characters]"
@@ -103,7 +120,7 @@ def _shrink_tool(message: ToolChatMessage, max_chars: int) -> ToolChatMessage:
return message
return msgspec.structs.replace(
message,
content=truncate_tool_result(message.content, max_chars),
content=truncate_tool_result(message.content, max_chars, hint=False),
)
+72 -16
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from typing import TYPE_CHECKING
from msgspec import UNSET
@@ -25,7 +26,6 @@ from .events import UsageEvent
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
from .todo_nag import (
DEFAULT_TODO_NAG_STRATEGY,
inject_todo_nag_for_stack_with_events,
parse_todo_nag_strategy,
refresh_synthetic_todo_nags,
)
@@ -38,14 +38,74 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
from plyngent.memory import MemoryStore
from .client import ChatClient
from .events import AgentEvent
from .todo_nag import TodoNagStrategy
from .todo_stack import TodoStack
from .types import AnyLLMClient
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
# Per-conversation tracking of lines read with ``with_lineno=True``, keyed by
# resolved file path. Set to a fresh ``dict`` at the start of each user turn via
# :func:`reset_lineno_tracker`; cleared after the assistant finishes.
# Tool handlers read/write via :func:`get_lineno_read_files` /
# :func:`lineno_read_lines` / :func:`mark_lineno_read`.
_lineno_read_files: ContextVar[dict[str, set[int]] | None] = ContextVar("_lineno_read_files", default=None)
def reset_lineno_tracker() -> Token[dict[str, set[int]] | None]:
"""Replace the tracker with a fresh empty mapping; return the previous token.
Call at the start of a new user turn. Restore with
``_lineno_read_files.reset(token)`` when the turn completes.
"""
return _lineno_read_files.set({})
def get_lineno_read_files() -> set[str]:
"""Return the current turn's set of line-numbered file paths.
Returns an empty set when no turn is active (tools will reject edits).
"""
val = _lineno_read_files.get()
return set(val) if val is not None else set()
def lineno_read_lines(path: str) -> set[int]:
"""Return the set of 1-based line numbers read this turn for *path*.
Returns an empty set when no turn is active (tools will reject edits).
"""
val = _lineno_read_files.get()
if val is None:
return set()
return set(val.get(path, ()))
def mark_lineno_read(path: str, lines: set[int]) -> None:
"""Record that *path* was read with line numbers this turn.
``lines`` are 1-based absolute file line numbers (``read_file`` slice).
"""
val = _lineno_read_files.get()
if val is not None:
val.setdefault(path, set()).update(lines)
def invalidate_lineno_read(path: str) -> None:
"""Drop all read-line state for *path* (file changed under the model)."""
val = _lineno_read_files.get()
if val is not None:
_ = val.pop(path, None)
def was_lineno_read(path: str) -> bool:
"""Return True if any line of *path* was read with line numbers this turn."""
val = _lineno_read_files.get()
return val is not None and bool(val.get(path))
def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
"""User text for an incomplete turn, if history can be retried.
@@ -117,7 +177,7 @@ def committed_prefix_end(messages: Sequence[AnyChatMessage], user_index: int) ->
class ChatAgent:
"""Thin wrapper: chat client + optional tools + optional memory bind."""
client: ChatClient
client: AnyLLMClient
model: str
tools: ToolRegistry | None
memory: MemoryStore | None
@@ -134,6 +194,7 @@ class ChatAgent:
todo_nag_strategy: TodoNagStrategy
directive_reminder_tokens: int
directive_reminder_text: str | None
provider_tools: list[dict[str, object]]
messages: list[AnyChatMessage]
session_usage: TokenUsage
last_turn_usage: TokenUsage
@@ -146,7 +207,7 @@ class ChatAgent:
def __init__(
self,
client: ChatClient,
client: AnyLLMClient,
*,
model: str,
tools: ToolRegistry | None = None,
@@ -167,6 +228,7 @@ class ChatAgent:
directive_reminder_text: str | None = None,
peak_prompt_tokens: int = 0,
reminder_last_band: int = 0,
provider_tools: Sequence[dict[str, object]] | None = None,
) -> None:
self.client = client
self.model = model
@@ -186,6 +248,7 @@ class ChatAgent:
self.directive_reminder_tokens = max(0, int(directive_reminder_tokens))
text = directive_reminder_text.strip() if directive_reminder_text else ""
self.directive_reminder_text = text or None
self.provider_tools = [dict(item) for item in provider_tools] if provider_tools else []
self.messages = list(messages) if messages is not None else []
self.session_usage = TokenUsage()
self.last_turn_usage = TokenUsage()
@@ -398,17 +461,7 @@ class ChatAgent:
turn_rounds = 0
last_request = TokenUsage()
try:
# Turn-start nag before first completion; yield events so CLI flushes
# (synthetic_tool → ToolCall/Result chrome, not glued to text).
if self.todo_stack is not None and not self.todo_stack.is_empty():
_injected, nag_events = inject_todo_nag_for_stack_with_events(
self.messages,
self.todo_stack,
kind="turn_start",
strategy=self.todo_nag_strategy,
)
for nag_event in nag_events:
yield nag_event
_turn_nag_strategy: TodoNagStrategy = self.todo_nag_strategy
async for event in run_chat_loop(
self.client,
@@ -423,11 +476,12 @@ class ChatAgent:
parallel_tools=self.parallel_tools,
max_context_tokens=self.max_context_tokens,
todo_stack=self.todo_stack,
todo_nag_strategy=self.todo_nag_strategy,
todo_nag_strategy=_turn_nag_strategy,
directive_reminder_tokens=self.directive_reminder_tokens,
directive_reminder_text=self.directive_reminder_text,
reminder_last_band=self.reminder_last_band,
on_reminder_band=self._on_reminder_band,
provider_tools=self.provider_tools or None,
):
if isinstance(event, UsageEvent):
turn_rounds += 1
@@ -463,6 +517,7 @@ class ChatAgent:
async def run(self, user_text: str) -> AsyncIterator[AgentEvent]:
"""Append a user message (persist immediately), run the tool loop, yield events."""
_ = reset_lineno_tracker()
self._ensure_system_prompt()
user_msg = UserChatMessage(content=user_text)
self.messages.append(user_msg)
@@ -560,6 +615,7 @@ class ChatAgent:
todo_nag_strategy="none",
directive_reminder_tokens=0,
messages=history,
provider_tools=self.provider_tools or None,
)
async for event in aside.run(text):
yield event
-35
View File
@@ -1,35 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Protocol, overload
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
)
class ChatClient(Protocol):
"""Structural protocol for OpenAI-compatible chat completion clients.
When ``stream=True``, implementations may return an async iterator from an
async method (``await client.chat_completions(..., stream=True)`` then
``async for chunk in stream``). That library shape is intentional.
"""
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: ...
+32 -4
View File
@@ -1,12 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, cast
from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionToolCall,
ChatCompletionResponse,
ChatCompletionsParam,
DeveloperChatMessage,
SystemChatMessage,
@@ -18,9 +19,11 @@ from .budget import DEFAULT_CONTEXT_MAX_TOKENS, compact_messages_for_request
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.lmproto.openai_compatible.client import OpenAICompatibleClient
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
from .client import ChatClient
from .types import AnyLLMClient
_SUMMARY_SYSTEM = (
"You compress chat histories for a coding agent. "
@@ -88,7 +91,7 @@ def soft_compact_transcript(
async def summarize_messages(
client: ChatClient,
client: AnyLLMClient,
messages: Sequence[AnyChatMessage],
*,
model: str,
@@ -140,7 +143,32 @@ async def summarize_messages(
model=model,
temperature=temperature if temperature is not None else UNSET,
)
response = await client.chat_completions(param, stream=False)
kind = getattr(client, "kind", "chat_completions")
response: ChatCompletionResponse
if kind == "chat_completions":
response = await cast("OpenAICompatibleClient", client).chat_completions(param, stream=False)
elif kind == "responses":
from .responses_dispatch import dispatch_responses
response = cast(
"ChatCompletionResponse",
await dispatch_responses(
cast("Any", client),
param,
provider_tools=None,
stream=False,
),
)
elif kind == "messages":
from .messages_dispatch import dispatch_messages
response = cast(
"ChatCompletionResponse",
await dispatch_messages(cast("Any", client), param, stream=False),
)
else:
msg = f"summarization for client.kind={kind!r} is not implemented"
raise NotImplementedError(msg)
if not response.choices:
msg = "summarization response contained no choices"
raise RuntimeError(msg)
+2 -1
View File
@@ -19,7 +19,8 @@ DEFAULT_DIRECTIVE_REMINDER_TEXT = """\
Tool playbook still applies (see system). Hard constraints:
- Prefer file tools over shell; several `run_command` calls may run in parallel; \
use `run_command_batch` for ordered pipelines.
- `edit_replace`: fix match / `max_replaces`; `read_file` with_lineno before `edit_lineno`.
- `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.
- Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO).
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
- Todo stack: open items mean unfinished work.
+33
View File
@@ -0,0 +1,33 @@
"""Canonical chat-completions ``finish_reason`` vocabulary for provider bridges.
Provider statuses/stops differ (Responses ``status``/``incomplete_details``,
Anthropic ``stop_reason``, DeepSeek reasoning, …); the agent loop and tool
execution only understand the chat-completions vocabulary. This module is the
single mapping point between raw provider reasons and that vocabulary.
"""
from __future__ import annotations
_LENGTH_REASONS = frozenset({"max_tokens", "max_output_tokens", "length"})
_CONTENT_FILTER_REASONS = frozenset({"content_filter", "refusal"})
_TOOL_REASONS = frozenset({"tool_use", "tool_calls"})
def chat_finish_reason(reason: str | None, *, has_tool_calls: bool) -> str:
"""Map a raw provider reason onto the chat ``finish_reason`` vocabulary.
Canonical values: ``stop``, ``length``, ``content_filter``, ``tool_calls``.
Provider-specific stop indicators (``end_turn``, ``stop_sequence``,
``completed``, ``None``, …) collapse to ``stop``.
"""
raw = (reason or "").lower() or None
if raw in _LENGTH_REASONS:
return "length"
if raw in _CONTENT_FILTER_REASONS:
return "content_filter"
if raw in _TOOL_REASONS or has_tool_calls:
return "tool_calls"
return "stop"
__all__ = ["chat_finish_reason"]
+73 -14
View File
@@ -2,16 +2,19 @@ from __future__ import annotations
import asyncio
import inspect
from typing import TYPE_CHECKING, cast
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any, cast
import msgspec
from msgspec import UNSET
from plyngent.lmproto.openai_compatible.client import merge_stream_tool_calls
from plyngent.lmproto.openai_compatible.client import OpenAICompatibleClient, merge_stream_tool_calls
from plyngent.lmproto.openai_compatible.model import (
AnyAssistantToolCall,
AssistantChatMessage,
AssistantFunctionToolCall,
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
StreamOptions,
StreamToolCallDelta,
@@ -51,12 +54,14 @@ from .usage import resolve_round_usage, token_usage_from_api
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from plyngent.lmproto.anthropic.client import AnthropicClient
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from .client import ChatClient
from .todo_nag import TodoNagStrategy
from .todo_stack import TodoStack
from .tools import ToolRegistry
from .types import AnyLLMClient
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
@@ -185,11 +190,48 @@ def _validate_assistant_terminal(
raise RuntimeError(msg)
async def _non_stream_round(
client: ChatClient,
async def _dispatch_chat_completions(
client: AnyLLMClient,
param: ChatCompletionsParam,
*,
stream: bool,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
kind = getattr(client, "kind", "chat_completions")
if kind == "chat_completions":
cc = cast("OpenAICompatibleClient", client)
return await cc.chat_completions(param, stream=stream)
if kind == "responses":
from .responses_dispatch import dispatch_responses
return await dispatch_responses(
cast("OpenAIClient", client),
param,
provider_tools=provider_tools,
stream=stream,
)
if kind == "messages":
from .messages_dispatch import dispatch_messages
return await dispatch_messages(
cast("AnthropicClient", client),
param,
stream=stream,
)
msg = f"client kind {kind!r} is not supported by the agent loop"
raise NotImplementedError(msg)
async def _non_stream_round(
client: AnyLLMClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AgentEvent]:
response = await client.chat_completions(param, stream=False)
response = cast(
"ChatCompletionResponse",
await _dispatch_chat_completions(client, param, stream=False, provider_tools=provider_tools),
)
if not response.choices:
msg = "chat completion response contained no choices"
raise RuntimeError(msg)
@@ -213,8 +255,10 @@ async def _non_stream_round(
async def _stream_round(
client: ChatClient,
client: AnyLLMClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AgentEvent]:
"""Stream one completion; yield text deltas as chunks arrive, then assistant.
@@ -227,7 +271,10 @@ async def _stream_round(
param,
stream_options=StreamOptions(include_usage=True),
)
stream = await client.chat_completions(stream_param, stream=True)
stream = cast(
"AsyncIterator[ChatCompletionChunk]",
await _dispatch_chat_completions(client, stream_param, stream=True, provider_tools=provider_tools),
)
content_parts: list[str] = []
reasoning_parts: list[str] = []
tool_deltas: list[StreamToolCallDelta] = []
@@ -286,20 +333,21 @@ async def _stream_round(
async def _assistant_round(
client: ChatClient,
client: AnyLLMClient,
param: ChatCompletionsParam,
messages: list[AnyChatMessage],
*,
stream: bool,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AgentEvent]:
"""One model turn: yield events and append the assistant message to ``messages``."""
if stream:
async for event in _stream_round(client, param):
async for event in _stream_round(client, param, provider_tools=provider_tools):
if isinstance(event, AssistantMessageEvent):
messages.append(event.message)
yield event
return
async for event in _non_stream_round(client, param):
async for event in _non_stream_round(client, param, provider_tools=provider_tools):
if isinstance(event, AssistantMessageEvent):
messages.append(event.message)
yield event
@@ -346,7 +394,7 @@ async def _maybe_inject_directive_checkpoints(
async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
client: ChatClient,
client: AnyLLMClient,
messages: list[AnyChatMessage],
*,
model: str,
@@ -364,6 +412,7 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
directive_reminder_text: str | None = None,
reminder_last_band: int = 0,
on_reminder_band: Callable[[int], Awaitable[None] | None] | None = None,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AgentEvent]:
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
@@ -372,6 +421,10 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
Multiple tool calls in one round run in parallel when ``parallel_tools``.
Request payloads may shrink older tool results when over ``max_context_tokens``.
*provider_tools* are hosted/provider-side tools (OpenAI Responses only, e.g.
``web_search``) merged into the request as opaque dicts; never executed by the
:class:`ToolRegistry`. Ignored by non-Responses clients.
When *todo_stack* is set and still needs review after a natural stop
(open items, or non-empty stack untouched this turn), injects a review nag
(channel from *todo_nag_strategy*) once so the model reconciles unfinished work.
@@ -416,7 +469,13 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
pre_len = len(messages)
last_usage_event: UsageEvent | None = None
async for event in _assistant_round(client, param, messages, stream=stream):
async for event in _assistant_round(
client,
param,
messages,
stream=stream,
provider_tools=provider_tools,
):
if isinstance(event, UsageEvent):
# Next rounds scale char-estimates by real/resolved prompt size.
prompt_tokens_hint = event.usage.prompt_tokens
@@ -465,7 +524,7 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
continue
return
reason = f"tool loop reached {allowance} rounds (used {rounds_used})"
reason = f"tool loop limit reached ({allowance} rounds)"
if on_limit is not None and await _call_on_limit(on_limit, reason):
yield MaxRoundsEvent(rounds=allowance, continued=True)
allowance += max_rounds
+311
View File
@@ -0,0 +1,311 @@
"""Convert agent chat history/tools to Anthropic Messages API shapes and back.
Agent memory and events stay chat-completions-shaped; only the transport uses
``POST /messages``. Real Anthropic and the DeepSeek ``convention="anthropic"``
surface both route through this module; OpenAI / openai-compatible paths never
enter it.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, cast
from msgspec import UNSET
from plyngent.lmproto.anthropic.model import (
AnthropicAssistantMessage,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicResponseText,
AnthropicTextContent,
AnthropicToolChoice,
AnthropicToolDefinition,
AnthropicToolResultContent,
AnthropicToolUseContent,
AnthropicUsage,
AnthropicUserMessage,
)
from plyngent.lmproto.openai_compatible.model import (
AnyAssistantToolCall,
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionChoice,
ChatCompletionResponse,
ChatCompletionsParam,
DeveloperChatMessage,
SystemChatMessage,
ToolFunctionItem,
UserChatMessage,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.lmproto.anthropic.model import AnthropicMessage
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from plyngent.typedef import Unset
def _parse_tool_arguments(arguments: str) -> dict[str, Any]:
"""Decode tool-call argument JSON into an object; empty/invalid → ``{}``."""
text = arguments.strip() if arguments else ""
if not text:
return {}
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return cast("dict[str, Any]", parsed)
return {}
def _encode_tool_arguments(obj: dict[str, Any]) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def tool_items_to_anthropic_tools(
tools: Sequence[AnyToolItem] | None,
) -> list[AnthropicToolDefinition]:
"""Map chat ``ToolFunctionItem`` list to Anthropic tool definitions."""
if not tools:
return []
result: list[AnthropicToolDefinition] = []
for item in tools:
if not isinstance(item, ToolFunctionItem):
continue
fn = item.function
result.append(
AnthropicToolDefinition(
name=fn.name,
description=fn.description if fn.description is not UNSET else UNSET,
input_schema=fn.parameters if fn.parameters is not UNSET else UNSET,
)
)
return result
def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice | Unset:
if param.tool_choice is UNSET:
return UNSET
choice = param.tool_choice
if not isinstance(choice, str):
return UNSET
if choice in {"none", "auto"}:
# Anthropic has no "none"; keep auto (callers can omit tools for hard none).
return AnthropicToolChoice(type="auto")
if choice == "required":
return AnthropicToolChoice(type="any")
return UNSET
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
blocks: list[AnthropicTextContent | AnthropicToolUseContent] = []
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:
for call in message.tool_calls:
if not isinstance(call, AssistantFunctionToolCall):
continue
blocks.append(
AnthropicToolUseContent(
id=call.id,
name=call.function.name,
input=_parse_tool_arguments(call.function.arguments),
)
)
if not blocks:
return None
if len(blocks) == 1 and isinstance(blocks[0], AnthropicTextContent):
return AnthropicAssistantMessage(content=blocks[0].text)
return AnthropicAssistantMessage(content=blocks)
def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
messages: Sequence[AnyChatMessage],
) -> tuple[str | None, list[AnthropicMessage]]:
"""Split leading system prompts; convert the rest to Anthropic messages.
Mid-turn developer messages become user text with a ``[developer]`` prefix
(Anthropic has no developer role). Consecutive tool results are merged into
a single user message with multiple ``tool_result`` blocks, which is the
shape Anthropic expects after an assistant ``tool_use`` turn.
"""
system_parts: list[str] = []
out: list[AnthropicMessage] = []
pending_tool_results: list[AnthropicToolResultContent] = []
def flush_tool_results() -> None:
nonlocal pending_tool_results
if not pending_tool_results:
return
out.append(AnthropicUserMessage(content=list(pending_tool_results)))
pending_tool_results = []
for message in messages:
if isinstance(message, SystemChatMessage):
# Leading system only becomes top-level ``system``; mid-history
# system (unusual) is treated as developer-style control text.
if not out and not pending_tool_results:
if message.content.strip():
system_parts.append(message.content)
else:
flush_tool_results()
if message.content.strip():
out.append(AnthropicUserMessage(content=f"[system] {message.content}"))
continue
if isinstance(message, DeveloperChatMessage):
flush_tool_results()
if message.content.strip():
out.append(AnthropicUserMessage(content=f"[developer] {message.content}"))
continue
if isinstance(message, UserChatMessage):
flush_tool_results()
out.append(AnthropicUserMessage(content=message.content))
continue
if isinstance(message, AssistantChatMessage):
flush_tool_results()
converted = _assistant_to_anthropic(message)
if converted is not None:
out.append(converted)
continue
# ToolChatMessage
pending_tool_results.append(
AnthropicToolResultContent(
tool_use_id=message.tool_call_id,
content=message.content,
)
)
flush_tool_results()
system = "\n\n".join(system_parts) if system_parts else None
return system, out
def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> AssistantChatMessage:
"""Map a completed Anthropic message to agent ``AssistantChatMessage``."""
text_parts: list[str] = []
tool_calls: list[AnyAssistantToolCall] = []
for block in response.content:
if isinstance(block, AnthropicResponseText):
if block.text:
text_parts.append(block.text)
continue
# AnthropicResponseToolUse (remaining content arm)
tool_calls.append(
AssistantFunctionToolCall(
id=block.id,
function=AssistantFunctionTool(
name=block.name,
arguments=_encode_tool_arguments(block.input),
),
)
)
text = "".join(text_parts)
return AssistantChatMessage(
content=text or None,
tool_calls=tool_calls or UNSET,
)
def anthropic_stop_to_finish_reason(
stop_reason: str | None,
*,
has_tool_calls: bool,
) -> str:
"""Map Anthropic ``stop_reason`` to a chat-style finish_reason."""
from .finish_reason import chat_finish_reason
return chat_finish_reason(stop_reason, has_tool_calls=has_tool_calls)
def anthropic_usage_to_dict(usage: AnthropicUsage | dict[str, Any] | None) -> dict[str, Any] | None:
"""Normalize Anthropic usage Struct/dict to chat-compatible usage fields."""
from .usage import token_usage_from_api
parsed = token_usage_from_api(usage)
if parsed is None:
return None
return {
"prompt_tokens": parsed.prompt_tokens,
"completion_tokens": parsed.completion_tokens,
"total_tokens": parsed.total_tokens,
"input_tokens": parsed.prompt_tokens,
"output_tokens": parsed.completion_tokens,
}
def anthropic_response_to_chat_completion(
response: AnthropicMessageResponse,
) -> ChatCompletionResponse:
"""Wrap an Anthropic message as a synthetic chat completion for the agent loop."""
assistant = anthropic_response_to_assistant(response)
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = anthropic_stop_to_finish_reason(response.stop_reason, has_tool_calls=has_tools)
usage = anthropic_usage_to_dict(response.usage)
return ChatCompletionResponse(
id=response.id,
object="chat.completion",
created=0,
model=response.model or "",
choices=[
ChatCompletionChoice(
index=0,
message=assistant,
finish_reason=cast("Any", finish),
)
],
usage=cast("Any", usage) if usage is not None else UNSET,
)
def _max_tokens_from_param(param: ChatCompletionsParam) -> int:
if param.max_completion_tokens is not UNSET:
return int(param.max_completion_tokens)
if param.max_tokens is not UNSET:
return int(param.max_tokens)
return 8192
def chat_param_to_anthropic_param(param: ChatCompletionsParam) -> AnthropicMessagesParam:
"""Build :class:`AnthropicMessagesParam` from a chat completions param."""
system, messages = chat_messages_to_anthropic(param.messages)
tools = tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None)
kwargs: dict[str, Any] = {
"model": param.model,
"max_tokens": _max_tokens_from_param(param),
"messages": messages,
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = tools
tool_choice = _tool_choice_from_chat(param)
if tool_choice is not UNSET:
kwargs["tool_choice"] = tool_choice
if param.parallel_tool_calls is not UNSET and param.parallel_tool_calls is False:
existing = kwargs.get("tool_choice")
if isinstance(existing, AnthropicToolChoice):
kwargs["tool_choice"] = AnthropicToolChoice(
type=existing.type,
name=existing.name,
disable_parallel_tool_use=True,
)
else:
kwargs["tool_choice"] = AnthropicToolChoice(
type="auto",
disable_parallel_tool_use=True,
)
if param.temperature is not UNSET:
kwargs["temperature"] = param.temperature
if param.top_p is not UNSET:
kwargs["top_p"] = param.top_p
return AnthropicMessagesParam(**kwargs)
+259
View File
@@ -0,0 +1,259 @@
"""Anthropic Messages dispatch: present ``/messages`` as chat completions to the loop.
Agent memory and events stay chat-completions-shaped; only the transport uses
``POST /messages``. Real Anthropic and the DeepSeek ``convention="anthropic"``
surface both route through this module; OpenAI / openai-compatible paths never
enter it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Final
from plyngent.agent.messages_bridge import (
anthropic_response_to_chat_completion,
anthropic_stop_to_finish_reason,
anthropic_usage_to_dict,
chat_param_to_anthropic_param,
)
from plyngent.agent.stream_chunks import (
finish_reason_chunk,
text_delta_chunk,
tool_call_delta_chunk,
usage_chunk,
)
from plyngent.lmproto.anthropic.model import (
AnthropicContentBlockDelta,
AnthropicContentBlockStart,
AnthropicErrorEvent,
AnthropicMessageDelta,
AnthropicMessageStart,
AnthropicMessageStop,
AnthropicRawContentBlock,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from plyngent.lmproto.anthropic.client import AnthropicClient
from plyngent.lmproto.anthropic.model import AnthropicStreamEvent
from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
)
class _ToolBlockState:
"""Tracks an in-flight tool_use content block during SSE streaming."""
__slots__: Final = ("call_id", "index", "name", "tool_index")
index: int
call_id: str
name: str
tool_index: int
def __init__(self, *, index: int, call_id: str, name: str, tool_index: int) -> None:
self.index = index
self.call_id = call_id
self.name = name
self.tool_index = tool_index
def _merge_usage(previous: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]:
"""Merge message_start input tokens with message_delta output tokens."""
if previous is None:
return new
prompt = int(previous.get("prompt_tokens", 0) or new.get("prompt_tokens", 0) or 0)
completion = int(new.get("completion_tokens", 0) or previous.get("completion_tokens", 0) or 0)
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": prompt + completion,
"input_tokens": prompt,
"output_tokens": completion,
}
def _error_detail(error: dict[str, Any]) -> str:
msg = error.get("message")
if isinstance(msg, str) and msg.strip():
return msg
return str(error)
class _StreamState:
"""Mutable accumulator while converting Anthropic SSE to chat chunks."""
model: str
tool_blocks: dict[int, _ToolBlockState]
next_tool_index: int
stop_reason: str | None
last_usage: dict[str, Any] | None
saw_message_stop: bool
has_tool_calls: bool
def __init__(self, model: str) -> None:
self.model = model
self.tool_blocks = {}
self.next_tool_index = 0
self.stop_reason = None
self.last_usage = None
self.saw_message_stop = False
self.has_tool_calls = False
def start_tool(self, *, block_index: int, call_id: str, name: str) -> _ToolBlockState:
state = _ToolBlockState(
index=block_index,
call_id=call_id,
name=name,
tool_index=self.next_tool_index,
)
self.tool_blocks[block_index] = state
self.next_tool_index += 1
self.has_tool_calls = True
return state
def ensure_tool(self, block_index: int) -> _ToolBlockState:
state = self.tool_blocks.get(block_index)
if state is not None:
return state
return self.start_tool(block_index=block_index, call_id=f"toolu_{block_index}", name="")
def _handle_tool_use_start(
event: AnthropicContentBlockStart,
state: _StreamState,
) -> list[ChatCompletionChunk]:
block = event.content_block
if block.type != "tool_use":
return []
call_id = block.id or f"toolu_{event.index}"
name = block.name or ""
tool_state = state.start_tool(block_index=event.index, call_id=call_id, name=name)
return [
tool_call_delta_chunk(
model=state.model,
index=tool_state.tool_index,
call_id=call_id,
name=name,
arguments="",
)
]
def _handle_message_delta(event: AnthropicMessageDelta, state: _StreamState) -> None:
raw_stop = event.delta.get("stop_reason")
if isinstance(raw_stop, str) and raw_stop:
state.stop_reason = raw_stop
usage = anthropic_usage_to_dict(event.usage)
if usage is not None:
state.last_usage = _merge_usage(state.last_usage, usage)
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):
detail = _error_detail(event.error)
msg = f"anthropic stream error: {detail}"
raise RuntimeError(msg) # noqa: TRY004 — stream transport failure
if isinstance(event, AnthropicMessageStart):
usage = anthropic_usage_to_dict(event.message.usage)
if usage is not None:
state.last_usage = usage
return []
if isinstance(event, AnthropicContentBlockStart):
return _handle_tool_use_start(event, state)
if isinstance(event, AnthropicContentBlockDelta):
return _chunks_for_content_delta(event, state)
if isinstance(event, AnthropicMessageDelta):
_handle_message_delta(event, state)
return []
if isinstance(event, AnthropicMessageStop):
state.saw_message_stop = True
return []
def _chunks_for_content_delta(
event: AnthropicContentBlockDelta,
state: _StreamState,
) -> list[ChatCompletionChunk]:
delta: AnthropicRawContentBlock = event.delta
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)]
is_json_delta = delta.type in {"input_json_delta", "input_json"} or (
isinstance(delta.partial_json, str) and bool(delta.partial_json)
)
if not is_json_delta:
return []
tool_state = state.ensure_tool(event.index)
fragment = delta.partial_json if isinstance(delta.partial_json, str) else ""
if not fragment:
return []
return [
tool_call_delta_chunk(
model=state.model,
index=tool_state.tool_index,
arguments=fragment,
)
]
async def _stream_as_chat_chunks(
client: AnthropicClient,
param: ChatCompletionsParam,
) -> AsyncIterator[ChatCompletionChunk]:
"""Yield chat-completions chunks from an Anthropic Messages SSE stream.
Text deltas stream as they arrive. Tool calls stream as OpenAI-style
tool-call deltas (id/name on start, argument JSON fragments on delta).
A finish_reason (and optional usage) is emitted on ``message_delta`` /
``message_stop``. Errors raise ``RuntimeError`` so the agent loop can
surface them as retryable failures.
"""
create = chat_param_to_anthropic_param(param)
stream = await client.messages(create, stream=True)
state = _StreamState(param.model)
async for event in stream:
for chunk in _chunks_for_event(event, state):
yield chunk
if state.saw_message_stop:
break
if not state.saw_message_stop and state.stop_reason is None:
# Stream ended without a terminal signal (message_stop or message_delta
# stop_reason) — yield nothing more so the loop can treat empty + no
# finish_reason as a missing terminal glitch. Matches the Responses
# bridge contract (terminal only on response.completed).
return
finish = anthropic_stop_to_finish_reason(state.stop_reason, has_tool_calls=state.has_tool_calls)
yield finish_reason_chunk(model=state.model, finish_reason=finish)
if state.last_usage is not None:
yield usage_chunk(model=state.model, usage=state.last_usage)
async def dispatch_messages(
client: AnthropicClient,
param: ChatCompletionsParam,
*,
stream: bool = False,
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
"""Run one Anthropic Messages turn and return a chat-completions-shaped result."""
create = chat_param_to_anthropic_param(param)
if stream:
return _stream_as_chat_chunks(client, param)
response = await client.messages(create, stream=False)
return anthropic_response_to_chat_completion(response)
__all__ = ["dispatch_messages"]
+66 -100
View File
@@ -1,7 +1,8 @@
"""Convert agent chat history/tools to OpenAI Responses API shapes and back.
Agent memory and events stay chat-completions-shaped; only the transport uses
Responses. DeepSeek / openai-compatible paths never enter this module.
Responses. OpenAI Responses and DeepSeek Responses (``convention = "responses"``)
both use this bridge; openai-compatible chat-completions paths never enter it.
"""
from __future__ import annotations
@@ -27,11 +28,7 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
ChunkChoice,
DeltaMessage,
DeveloperChatMessage,
StreamFunctionDelta,
StreamToolCallDelta,
SystemChatMessage,
ToolFunctionItem,
UserChatMessage,
@@ -86,6 +83,49 @@ def _assistant_to_input_items(
return items
def _item_call_id(
item: dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput,
) -> str | None:
"""Return the call id for a ``function_call`` input item, else ``None``."""
if isinstance(item, ResponseFunctionToolCallOutput):
return item.call_id
if isinstance(item, dict) and item.get("type") == "function_call":
call_id = item.get("call_id")
return call_id if isinstance(call_id, str) else None
return None
def _interleave_tool_outputs(
items: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput],
) -> list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]:
"""Place each ``function_call_output`` immediately after its ``function_call``.
OpenAI matches outputs to calls by ``call_id`` anywhere in ``input``, but
DeepSeek's Responses implementation expects each ``function_call`` to be
directly followed by its ``function_call_output`` — a parallel batch sent
as ``call, call, output, output`` is rejected with "No tool output found
for tool call …". Interleaving is a no-op for single-call rounds and stays
valid for OpenAI (outputs still follow their calls).
"""
outputs_by_call: dict[str, list[ResponseFunctionToolCallOutput]] = {}
for item in items:
if isinstance(item, ResponseFunctionToolCallOutput):
outputs_by_call.setdefault(item.call_id, []).append(item)
out: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput] = []
for item in items:
if isinstance(item, ResponseFunctionToolCallOutput):
continue # re-added right after their call below
out.append(item)
call_id = _item_call_id(item)
if call_id is not None and call_id in outputs_by_call:
out.extend(outputs_by_call.pop(call_id))
# Defensive: outputs whose call was not seen keep stream order at the end.
for pending in outputs_by_call.values():
out.extend(pending)
return out
def chat_messages_to_responses_input(
messages: Sequence[AnyChatMessage],
) -> tuple[str | None, list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]]:
@@ -115,7 +155,7 @@ def chat_messages_to_responses_input(
)
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
return instructions, items
return instructions, _interleave_tool_outputs(items)
def response_to_assistant_message(response: Response) -> AssistantChatMessage:
@@ -165,20 +205,20 @@ def responses_status_to_finish_reason(
has_tool_calls: bool,
) -> str:
"""Map Responses ``status`` to a chat-style finish_reason for the agent loop."""
from .finish_reason import chat_finish_reason
status = response.status
status_s = status if isinstance(status, str) else None
if status_s == "incomplete":
details = response.incomplete_details
if details is not UNSET and details is not None:
raw_reason = details.reason
if raw_reason is not UNSET and raw_reason == "content_filter":
return "content_filter"
if raw_reason is not UNSET:
return chat_finish_reason(raw_reason, has_tool_calls=False)
return "length"
if status_s in {"failed", "cancelled"}:
return status_s
if has_tool_calls:
return "tool_calls"
return "stop"
return chat_finish_reason(status_s, has_tool_calls=has_tool_calls)
def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
@@ -204,58 +244,6 @@ def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
)
def finish_reason_chunk(
*,
model: str,
finish_reason: str,
created: int = 0,
) -> ChatCompletionChunk:
"""Terminal stream chunk carrying only ``finish_reason`` (no delta text)."""
return ChatCompletionChunk(
id="resp-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(),
finish_reason=cast("Any", finish_reason),
)
],
)
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="resp-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(content=content),
)
],
)
def reasoning_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="resp-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(reasoning_content=content),
)
],
)
def tool_call_chunks_from_response(
response: Response,
*,
@@ -263,50 +251,28 @@ def tool_call_chunks_from_response(
created: int = 0,
) -> list[ChatCompletionChunk]:
"""Emit complete tool-call stream deltas (one chunk per call) for loop merge."""
from .stream_chunks import tool_call_delta_chunk
calls = response_function_calls(response)
chunks: list[ChatCompletionChunk] = []
for index, call in enumerate(calls):
chunks.append(
ChatCompletionChunk(
id=response.id,
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(
tool_calls=[
StreamToolCallDelta(
index=index,
id=call.call_id,
type="function",
function=StreamFunctionDelta(
name=call.name,
arguments=call.arguments,
),
)
]
),
)
],
)
return [
tool_call_delta_chunk(
model=model,
index=index,
call_id=call.call_id,
name=call.name,
arguments=call.arguments,
created=created,
)
return chunks
for index, call in enumerate(calls)
]
def usage_chunk_from_response(response: Response, *, model: str) -> ChatCompletionChunk | None:
from .stream_chunks import usage_chunk
if response.usage is UNSET or response.usage is None:
return None
created = int(response.created_at)
return ChatCompletionChunk(
id=response.id,
object="chat.completion.chunk",
created=created,
model=model,
choices=[],
usage=response.usage,
)
return usage_chunk(model=model, usage=response.usage, created=int(response.created_at))
def _merge_response_tools(
-137
View File
@@ -1,137 +0,0 @@
"""ChatClient adapter: agent chat loop over OpenAI Responses API."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, overload
from msgspec import UNSET
from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
finish_reason_chunk,
reasoning_delta_chunk,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
text_delta_chunk,
tool_call_chunks_from_response,
usage_chunk_from_response,
)
from plyngent.lmproto.openai.model import ResponsesCreateParam
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai.model import Response
from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
)
class ResponsesChatClient:
"""Present OpenAI Responses as :class:`~plyngent.agent.client.ChatClient`.
History and tool results remain chat-completions-shaped; only the HTTP call
uses ``POST /responses``. Optional *provider_tools* are hosted tools merged
into the request (not local registry handlers).
"""
_client: OpenAIClient
_provider_tools: list[dict[str, Any]]
def __init__(
self,
client: OpenAIClient,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> None:
self._client = client
self._provider_tools = [dict(t) for t in provider_tools] if provider_tools else []
async def models(self) -> list[str]:
return await self._client.models()
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
kwargs = chat_param_to_responses_kwargs(
param,
provider_tools=self._provider_tools or None,
)
create = ResponsesCreateParam(**kwargs)
if stream:
return self._stream_as_chat_chunks(create, model=param.model)
response = await self._client.responses(create, stream=False)
return response_to_chat_completion(response)
async def _stream_as_chat_chunks(
self,
create: ResponsesCreateParam,
*,
model: str,
) -> AsyncIterator[ChatCompletionChunk]:
stream = await self._client.responses(create, stream=True)
final: Response | None = None
async for event in stream:
etype = event.type
if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta:
yield text_delta_chunk(model=model, content=event.delta)
continue
if (
etype
in {
"response.reasoning_summary_text.delta",
"response.reasoning_text.delta",
}
and isinstance(event.delta, str)
and event.delta
):
yield reasoning_delta_chunk(model=model, content=event.delta)
continue
if etype == "response.completed" and event.response is not UNSET:
# Decode full response for tools + usage (field is dict | Unset).
import msgspec
from plyngent.lmproto.openai.model import Response as ResponseModel
try:
final = msgspec.convert(event.response, ResponseModel)
except TypeError, ValueError, msgspec.ValidationError:
final = None
if final is None:
# Stream ended without response.completed — signal missing terminal.
# Loop will treat empty + no finish_reason as a glitch.
return
assistant = response_to_assistant_message(final)
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = responses_status_to_finish_reason(final, has_tool_calls=has_tools)
for chunk in tool_call_chunks_from_response(final, model=model):
yield chunk
yield finish_reason_chunk(model=model, finish_reason=finish)
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
def wrap_openai_for_agent(
client: OpenAIClient,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> ResponsesChatClient:
"""Wrap a platform OpenAI client so the agent uses Responses by default."""
return ResponsesChatClient(client, provider_tools=provider_tools)
+161
View File
@@ -0,0 +1,161 @@
"""OpenAI Responses dispatch: present ``/responses`` as chat completions to the loop.
Agent memory and events stay chat-completions-shaped; only the transport uses
``POST /responses``. OpenAI Responses and DeepSeek Responses
(``convention = "responses"``) both route through this module; openai-compatible
chat-completions paths never enter it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, NoReturn, cast
import msgspec
from msgspec import UNSET
from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
tool_call_chunks_from_response,
usage_chunk_from_response,
)
from plyngent.agent.stream_chunks import (
finish_reason_chunk,
reasoning_delta_chunk,
text_delta_chunk,
)
from plyngent.lmproto.openai.model import (
Response as ResponseModel,
)
from plyngent.lmproto.openai.model import (
ResponsesCreateParam,
ResponseStreamEvent,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
)
# Responses stream event types that carry reasoning text deltas.
_REASONING_DELTA_TYPES = {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}
# Terminal events that carry the full Response object (status, tool calls, usage).
# ``response.failed`` also carries a Response but is surfaced as an error.
_FINAL_EVENT_TYPES = {"response.completed", "response.incomplete"}
def _failed_stream_detail(event: ResponseStreamEvent) -> str:
"""Human-readable detail for a ``response.failed`` stream event."""
if event.response is not UNSET:
raw_error: object = event.response.get("error")
if isinstance(raw_error, dict):
error_map = cast("dict[str, object]", raw_error)
code = error_map.get("code")
message = error_map.get("message")
if isinstance(message, str) and message.strip():
prefix = f"{code}: " if isinstance(code, str) and code.strip() else ""
return f"{prefix}{message.strip()}"
if isinstance(code, str) and code.strip():
return code.strip()
if isinstance(event.message, str) and event.message.strip():
return event.message.strip()
return "unknown failure"
def _raise_stream_error(event: ResponseStreamEvent) -> NoReturn:
"""Raise for Responses ``error`` / ``response.failed`` stream events."""
if event.type == "error":
code = event.code if event.code is not UNSET else ""
message = event.message if event.message is not UNSET else ""
detail = message.strip() or code
label = detail.strip() or event.type
raise RuntimeError(f"responses stream error: {label}") # noqa: TRY003 — transport failure
raise RuntimeError(f"responses stream failed: {_failed_stream_detail(event)}") # noqa: TRY003 — transport failure
def _decode_final_event(event: ResponseStreamEvent) -> ResponseModel | None:
"""Decode the full Response carried by a terminal stream event."""
if event.response is UNSET:
return None
try:
return msgspec.convert(event.response, ResponseModel)
except TypeError, ValueError, msgspec.ValidationError:
return None
async def _stream_as_chat_chunks(
client: OpenAIClient,
create: ResponsesCreateParam,
*,
model: str,
) -> AsyncIterator[ChatCompletionChunk]:
"""Yield chat-completions chunks synthesized from a Responses SSE stream.
Text / reasoning deltas stream as they arrive; tool calls, finish_reason,
and usage are emitted once on a terminal event (``response.completed`` /
``response.incomplete`` — the full Response is needed to decode function
calls + usage; ``response.incomplete`` maps to a ``length`` finish).
``response.failed`` raises with the embedded error detail. A stream that
ends without a terminal event yields nothing here — the loop treats that
as a missing terminal signal.
"""
stream = await client.responses(create, stream=True)
final: ResponseModel | None = None
async for event in stream:
etype = event.type
if etype in {"error", "response.failed"}:
_raise_stream_error(event)
if etype in _FINAL_EVENT_TYPES:
final = _decode_final_event(event)
continue
if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta:
yield text_delta_chunk(model=model, content=event.delta)
continue
if etype in _REASONING_DELTA_TYPES and isinstance(event.delta, str) and event.delta:
yield reasoning_delta_chunk(model=model, content=event.delta)
continue
if final is None:
# Stream ended without response.completed — signal missing terminal.
# The loop will treat empty + no finish_reason as a glitch.
return
assistant = response_to_assistant_message(final)
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = responses_status_to_finish_reason(final, has_tool_calls=has_tools)
for chunk in tool_call_chunks_from_response(final, model=model):
yield chunk
yield finish_reason_chunk(model=model, finish_reason=finish)
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
async def dispatch_responses(
client: OpenAIClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
stream: bool = False,
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
"""Run one Responses turn and return a chat-completions-shaped result.
*provider_tools* are hosted tools (web_search, file_search, …) as opaque
dicts merged after local function tools; never executed by the registry.
"""
kwargs = chat_param_to_responses_kwargs(param, provider_tools=provider_tools)
create = ResponsesCreateParam(**kwargs)
if stream:
return _stream_as_chat_chunks(client, create, model=param.model)
response = await client.responses(create, stream=False)
return response_to_chat_completion(response)
__all__ = ["dispatch_responses"]
+128
View File
@@ -0,0 +1,128 @@
"""Shared chat-completions stream chunk builders for protocol bridges.
Both the Responses bridge and the Anthropic Messages bridge synthesize
``ChatCompletionChunk`` objects so the agent loop can consume provider streams
uniformly. The generic builders below are provider-agnostic; provider bridges
map their events onto them.
"""
from __future__ import annotations
from typing import Any, cast
from msgspec import UNSET
from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChunkChoice,
DeltaMessage,
StreamFunctionDelta,
StreamToolCallDelta,
)
def finish_reason_chunk(
*,
model: str,
finish_reason: str,
created: int = 0,
) -> ChatCompletionChunk:
"""Terminal stream chunk carrying only ``finish_reason`` (no delta text)."""
return ChatCompletionChunk(
id="bridge-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(),
finish_reason=cast("Any", finish_reason),
)
],
)
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="bridge-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(content=content),
)
],
)
def reasoning_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="bridge-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(reasoning_content=content),
)
],
)
def tool_call_delta_chunk(
*,
model: str,
index: int,
call_id: str | None = None,
name: str | None = None,
arguments: str | None = None,
created: int = 0,
) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="bridge-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(
tool_calls=[
StreamToolCallDelta(
index=index,
id=call_id if call_id is not None else UNSET,
type="function",
function=StreamFunctionDelta(
name=name if name is not None else UNSET,
arguments=arguments if arguments is not None else UNSET,
),
)
]
),
)
],
)
def usage_chunk(*, model: str, usage: dict[str, Any], created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="bridge-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[],
usage=usage,
)
__all__ = [
"finish_reason_chunk",
"reasoning_delta_chunk",
"text_delta_chunk",
"tool_call_delta_chunk",
"usage_chunk",
]
+31
View File
@@ -69,6 +69,7 @@ def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> st
messages.append(
AssistantChatMessage(
content=UNSET,
reasoning_content="",
tool_calls=[
AssistantFunctionToolCall(
id=call_id,
@@ -130,9 +131,39 @@ def refresh_synthetic_todo_nags(
continue
messages[index] = ToolChatMessage(tool_call_id=msg.tool_call_id, content=body)
updated += 1
# Synthetic assistant messages (tool calls but no reasoning_content) trip
# DeepSeek's rule: "if the model performed a tool call between two user
# messages, the assistant's reasoning_content must be passed back."
# Inject empty reasoning_content so the API doesn't reject the request.
updated += _inject_reasoning_content_for_synthetic(messages)
return updated
def _inject_reasoning_content_for_synthetic(
messages: list[AnyChatMessage],
) -> int:
"""Inject ``reasoning_content=\"\"`` into synthetic nag assistants that lack it."""
injected = 0
for index, msg in enumerate(messages):
if not isinstance(msg, AssistantChatMessage):
continue
tool_calls = msg.tool_calls
if tool_calls is UNSET or not tool_calls:
continue
if not any(
isinstance(c, AssistantFunctionToolCall) and is_synthetic_todo_nag_call_id(c.id) for c in tool_calls
):
continue
if msg.reasoning_content is not UNSET:
continue
from msgspec import structs
messages[index] = structs.replace(msg, reasoning_content="")
injected += 1
return injected
def inject_todo_nag(
messages: list[AnyChatMessage],
body: str,
+4 -8
View File
@@ -392,14 +392,15 @@ class ToolRegistry:
*,
tags: ToolTag,
) -> str | None:
"""Soft-confirm gate driven by danger reason + tags + grants + YOLO.
"""Soft-confirm gate driven by danger reason + tags + grants.
Pipeline (soft gate only; hard denylists stay in tool handlers)::
no soft reason → run
YOLO mode and (tags & YOLO) → allow
(tags & TRUSTABLE) and grant exists → allow
else on_confirm; on approve + TRUSTABLE → store grant
else → on_confirm (decorator YOLO tag does not override
dangerous invocations — internal danger classification
always takes precedence)
"""
if self._danger is None:
return None
@@ -408,11 +409,6 @@ class ToolRegistry:
return None
async with self._confirm_lock:
reason = self._danger(name, args)
if reason is None:
return None
if self._yolo and (tags & ToolTag.YOLO):
return None
if self._grant_allows(name, tags=tags):
return None
return await self._prompt_soft_confirm(name, args, reason, tags=tags)
+21
View File
@@ -0,0 +1,21 @@
"""Agent-level type aliases for LLM client dispatch."""
from __future__ import annotations
from plyngent.lmproto.anthropic.client import AnthropicClient
from plyngent.lmproto.deepseek import (
DeepseekAnthropicClient,
DeepseekOpenAIClient,
DeepseekResponsesClient,
)
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai_compatible.client import OpenAICompatibleClient
type AnyLLMClient = (
OpenAICompatibleClient
| DeepseekOpenAIClient
| DeepseekResponsesClient
| DeepseekAnthropicClient
| OpenAIClient
| AnthropicClient
)
+7 -3
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, cast
import msgspec
from msgspec import UNSET, Struct
from plyngent.agent.budget import estimate_message_chars, estimate_messages_chars
@@ -86,13 +87,16 @@ def _as_nonneg_int(value: object) -> int:
def token_usage_from_api(usage: object) -> TokenUsage | None:
"""Parse OpenAI-style usage dict; return None if missing/empty.
"""Parse API usage into :class:`TokenUsage`; return None if missing/empty.
Accepts chat completions fields (``prompt_tokens`` / ``completion_tokens``)
and Responses fields (``input_tokens`` / ``output_tokens``).
Accepts chat completions fields (``prompt_tokens`` / ``completion_tokens``),
Responses fields (``input_tokens`` / ``output_tokens``), and msgspec Structs
carrying either pair (e.g. ``AnthropicUsage``).
"""
if usage is None or usage is UNSET:
return None
if isinstance(usage, Struct):
usage = msgspec.to_builtins(usage)
if not isinstance(usage, dict):
return None
raw = cast("dict[str, object]", usage)
+11 -2
View File
@@ -268,7 +268,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
preferred_model=preferred_model,
interactive=interactive,
):
client = create_client(provider)
client = create_client(provider, model=preferred_model)
try:
if client_supports_models(client):
remote_ids = await fetch_remote_model_ids(client)
@@ -299,6 +299,10 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
)
# Path denylist and policy confirm live on instance.workspace (no process bag).
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
from plyngent.tools.net import set_ssrf_assume_public_cidrs
# Clash Fake-IP etc.: treat configured CIDRs as public for fetch SSRF.
_ = set_ssrf_assume_public_cidrs(store.networking_config.ssrf_assume_public_cidrs)
if interactive:
from plyngent.cli.limits import prompt_policy_command_confirm, prompt_policy_fetch_confirm
from plyngent.tools.net import set_fetch_policy_confirm_hook
@@ -339,10 +343,15 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
if isinstance(state_obj, ReplState):
state_obj.instance_state.workspace.policy_confirm_hook = None
state_obj.instance_state.workspace.policy_allowed_commands.clear()
from plyngent.tools.net import clear_private_grants, set_fetch_policy_confirm_hook
from plyngent.tools.net import (
clear_private_grants,
clear_ssrf_assume_public_cidrs,
set_fetch_policy_confirm_hook,
)
set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
clear_private_grants(instance=state_obj.instance_state)
clear_ssrf_assume_public_cidrs()
await state_obj.instance_state.shutdown()
else:
# No ReplState: only PTY class cleanup (temps require instance allowlist).
+1 -1
View File
@@ -118,7 +118,7 @@ def _open_with_system_default(path: Path) -> None:
resolved = str(path.resolve())
if sys.platform == "win32":
try:
os.startfile(resolved) # type: ignore[attr-defined]
os.startfile(resolved)
except OSError as exc:
msg = f"failed to open with system default: {exc}"
raise click.ClickException(msg) from exc
+16 -6
View File
@@ -16,7 +16,6 @@ from plyngent.prompting import (
choose_async,
configure_prompting,
confirm,
confirm_async,
get_prompt_backend,
)
from plyngent.tools.process.pty_session import PtyManager
@@ -98,9 +97,17 @@ def _echo_tool_confirm(name: str, reason: str) -> None:
backend.echo()
def _echo_continue_limit(reason: str) -> None:
"""Echo the limit reason as a standalone line (not jammed into a confirm prompt)."""
backend = get_prompt_backend()
backend.secho(f"[limit] {reason}", fg="yellow")
backend.echo()
def _prompt_continue_limit_sync(reason: str) -> bool:
try:
return confirm(f"[limit] {reason}\nRaise limit and continue?", default=True)
_echo_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
except NonInteractiveError:
return False
@@ -114,10 +121,13 @@ def prompt_continue_limit(reason: str) -> bool:
async def prompt_continue_limit_async(reason: str) -> bool:
"""Async variant: confirm off the event loop so the turn is not cancelled."""
try:
return await confirm_async(
f"[limit] {reason}\nRaise limit and continue?",
default=True,
)
from plyngent.prompting import run_prompt_async
def _run() -> bool:
_echo_continue_limit(reason)
return confirm("Raise limit and continue?", default=True)
return await run_prompt_async(_run)
except NonInteractiveError:
return False
+15 -1
View File
@@ -22,6 +22,12 @@ def _echo_user(text: str) -> None:
click.echo(text)
def _echo_interrupted() -> None:
click.echo()
click.secho("interrupted", fg="yellow")
click.echo()
async def run_repl(state: ReplState) -> None:
"""Interactive chat loop with readline editing, history, and Tab completion."""
setup_readline(state)
@@ -47,7 +53,11 @@ async def run_repl(state: ReplState) -> None:
continue
if entry.startswith("/"):
cont = await handle_slash(state, entry)
try:
cont = await handle_slash(state, entry)
except KeyboardInterrupt:
_echo_interrupted()
continue
if not cont:
break
if state.pending_user_text is not None:
@@ -56,6 +66,8 @@ async def run_repl(state: ReplState) -> None:
_echo_user(text)
try:
_ = await run_user_text_with_retries(state.agent, text)
except KeyboardInterrupt:
_echo_interrupted()
finally:
state.expire_yolo_once()
continue
@@ -63,5 +75,7 @@ async def run_repl(state: ReplState) -> None:
_echo_user(entry)
try:
_ = await run_user_text_with_retries(state.agent, entry)
except KeyboardInterrupt:
_echo_interrupted()
finally:
state.expire_yolo_once()
+26 -17
View File
@@ -131,20 +131,36 @@ def _echo_turn_usage(agent: ChatAgent) -> None:
click.secho(f"[{'; '.join(parts)}]", fg="bright_black")
async def _wait_for_retry(attempt: int, max_retries: int, wait: float) -> bool:
click.secho(
f"auto-retry {attempt}/{max_retries} in {wait:g}s (Ctrl+C to cancel; then /retry later)",
fg="yellow",
)
def _echo_cancel_lines(*lines: str) -> None:
"""Print post-cancel lines, treating a further Ctrl+C as benign.
After a turn-cancel the asyncio SIGINT handler is removed, so a second
Ctrl+C during this output would otherwise raise KeyboardInterrupt and
exit the REPL instead of returning to the prompt.
"""
try:
for line in lines:
if line:
click.secho(line, fg="yellow")
else:
click.echo()
except KeyboardInterrupt:
pass
async def _wait_for_retry(attempt: int, max_retries: int, wait: float) -> bool:
try:
click.secho(
f"auto-retry {attempt}/{max_retries} in {wait:g}s (Ctrl+C to cancel; then /retry later)",
fg="yellow",
)
ok = await sleep_cancellable(wait)
except KeyboardInterrupt:
ok = False
if not ok:
click.secho("auto-retry cancelled; use /retry to try again", fg="yellow")
click.echo()
_echo_cancel_lines("auto-retry cancelled; use /retry to try again", "")
return False
click.secho(f"retrying ({attempt}/{max_retries})…", fg="yellow")
_echo_cancel_lines(f"retrying ({attempt}/{max_retries})…")
return True
@@ -170,17 +186,10 @@ async def run_turn_with_retries(
await run_cancellable(render_events(current()))
except asyncio.CancelledError:
# Do not auto-retry cancelled turns — user intent was stop, not retry.
click.echo()
click.secho(
"cancelled; user message kept — use /retry to try again",
fg="yellow",
)
click.echo()
_echo_cancel_lines("", "cancelled; user message kept — use /retry to try again", "")
return False
except KeyboardInterrupt:
click.echo()
click.secho("interrupted", fg="yellow")
click.echo()
_echo_cancel_lines("", "interrupted", "")
return False
except Exception as exc: # noqa: BLE001 — surface and optionally retry
click.secho(f"error: {exc}", fg="red")
+3 -1
View File
@@ -532,8 +532,10 @@ def status_cmd(state: ReplState) -> None:
last_rounds = state.agent.last_turn_rounds
ctx_tag = "api" if ctx_src == "api" else "est"
ctx_tilde = "" if ctx_src == "api" else "~"
effective = state.effective_provider
click.echo(
f"provider={state.provider_name} model={state.model}\n"
f"provider={state.provider_name} model={state.model} "
f"preset={effective.preset} url={effective.url}\n"
f"session={state.session_id} messages={len(state.agent.messages)} "
f"pending_retry={pending_disp}\n"
f"tools={'on' if state.tools_enabled else 'off'} "
+17 -6
View File
@@ -4,11 +4,14 @@ import contextlib
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast
from typing import TYPE_CHECKING, Literal
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
from plyngent.agent import ChatAgent, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.agent.todo_stack import TodoStack
if TYPE_CHECKING:
from plyngent.agent.types import AnyLLMClient
from plyngent.cli.models_source import (
DEFAULT_MODELS_CACHE_TTL,
client_supports_models,
@@ -16,6 +19,7 @@ from plyngent.cli.models_source import (
fetch_remote_model_ids,
model_choices_for_provider,
)
from plyngent.config import EffectiveProvider, resolve_effective_provider
from plyngent.memory.database.store import normalize_workspace
from plyngent.runtime import create_client
from plyngent.tools import InstanceState, SessionState
@@ -55,7 +59,7 @@ class ReplState:
yolo: YoloMode | None = None
# Set by /edit; REPL sends as the next user turn then clears.
pending_user_text: str | None = None
client: ChatClient = field(init=False)
client: AnyLLMClient = field(init=False)
agent: ChatAgent = field(init=False)
session_id: int | None = None
todo_stack: TodoStack = field(default_factory=TodoStack)
@@ -71,8 +75,7 @@ class ReplState:
_remote_models_error: str | None = field(default=None, init=False, repr=False)
def __post_init__(self) -> None:
# DeepSeek client uses a compatible but distinct param type; treat as ChatClient.
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
self.client = create_client(self.provider, model=self.model)
self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace
@@ -262,6 +265,7 @@ class ReplState:
directive_reminder_text=agent_cfg.directive_reminder_text or None,
peak_prompt_tokens=peak,
reminder_last_band=band,
provider_tools=self.effective_provider.provider_tools or None,
)
if last_req is not None and not last_req.is_zero():
agent.last_request_usage = last_req
@@ -274,7 +278,7 @@ class ReplState:
# Preserve live stream toggle if agent already exists.
if hasattr(self, "agent"):
self.stream_enabled = self.agent.stream
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
self.client = create_client(self.provider, model=self.model)
self.agent = self._make_agent()
# Restore history without re-marking already-stored messages as dirty.
self.agent.replace_messages(messages, persist_from=persist_from)
@@ -285,9 +289,16 @@ class ReplState:
self.invalidate_remote_models()
def _models_cache_key(self) -> tuple[str, str]:
# Remote model lists belong to the provider endpoint used for discovery,
# not model-level overrides (the model is not selected yet there).
url = getattr(self.provider, "url", "") or ""
return (self.provider_name, str(url))
@property
def effective_provider(self) -> EffectiveProvider:
"""Selected provider after model-level preset/url overrides."""
return resolve_effective_provider(self.provider, model=self.model)
def invalidate_remote_models(self) -> None:
"""Drop cached remote model catalog (provider/client change)."""
self._remote_models = None
+7
View File
@@ -10,15 +10,22 @@ from .models import DEFAULT_TOOL_DIRECTIVES as DEFAULT_TOOL_DIRECTIVES
from .models import AgentConfig as AgentConfig
from .models import AnthropicProvider as AnthropicProvider
from .models import DatabaseConfig as DatabaseConfig
from .models import DeepSeekConvention as DeepSeekConvention
from .models import DeepseekProvider as DeepseekProvider
from .models import HttpTimeoutConfig as HttpTimeoutConfig
from .models import ModelConfig as ModelConfig
from .models import NetworkingConfig as NetworkingConfig
from .models import OpenAICompatibleProvider as OpenAICompatibleProvider
from .models import OpenAIProvider as OpenAIProvider
from .models import PluginsConfig as PluginsConfig
from .models import ProviderConfig as ProviderConfig
from .models import compose_agent_system_content as compose_agent_system_content
from .path import get_default_path as _get_default_path
from .routing import EffectiveProvider as EffectiveProvider
from .routing import ProviderPreset as ProviderPreset
from .routing import default_url_for_preset as default_url_for_preset
from .routing import provider_preset as provider_preset
from .routing import resolve_effective_provider as resolve_effective_provider
from .store import ConfigFormatError as ConfigFormatError
from .store import ConfigStore as ConfigStore
+51 -4
View File
@@ -1,7 +1,15 @@
from typing import Any
from typing import Any, Literal
from msgspec import Struct, field
# DeepSeek API surface convention (``DeepseekProvider.convention`` and
# per-model ``ModelConfig.convention`` override).
# - "" (default) / "openai" → OpenAI-compatible chat completions
# - "responses" → OpenAI Responses API (``POST /responses``)
# - "anthropic" → Anthropic Messages API (``POST /messages`` on
# ``https://api.deepseek.com/anthropic``)
type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
# Built-in persona when ``[agent].system_prompt`` is omitted.
# Set ``system_prompt = ""`` to omit the persona block only.
# Override with a multi-line TOML literal (prefer ''' so nested " is fine).
@@ -21,7 +29,9 @@ DEFAULT_TOOL_DIRECTIVES = """\
fix `old_string` or `max_replaces`; shell workarounds rarely help.
- `edit_replace` defaults to the first match; if remaining matches are reported, \
raise `max_replaces` or narrow `old_string`.
- Before `edit_lineno`, `read_file` with `with_lineno=true`.
- `edit_lineno` edits only lines you read via `read_file(path, with_lineno=true)`; \
re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete, \
since line numbers go stale.
### Commands
- Prefer `run_command` / `run_command_batch` (argv, no shell) over `bash -c` or similar.
@@ -126,8 +136,29 @@ class PluginsConfig(Struct, omit_defaults=True):
disable: list[str] = field(default_factory=list)
class NetworkingConfig(Struct, omit_defaults=True):
"""Host-side network policy for tools such as ``fetch``.
``ssrf_assume_public_cidrs``: CIDR strings whose resolved addresses are treated
as **public for SSRF policy** (skip private-host grants). Use for Clash/meta
Fake-IP pools (commonly ``198.18.0.0/15``). Empty default = no exemptions.
Metadata ranges (e.g. ``169.254.169.254``) stay forbidden even if listed here.
Real loopback/RFC1918 literals are unaffected unless you put those CIDRs here
(not recommended).
"""
ssrf_assume_public_cidrs: list[str] = field(default_factory=list)
class ModelConfig(Struct, omit_defaults=True):
"""Capability flags for a model within a provider."""
"""Capability flags and optional routing overrides for a model.
``preset`` / ``url`` override the parent provider for this model only.
``preset`` keeps the same meanings as provider presets:
``openai`` → /responses, ``openai-compatible`` → /chat/completions,
``anthropic`` → /messages, ``deepseek`` → DeepSeek.
"""
text: bool = True
image_in: bool = False
@@ -137,6 +168,10 @@ class ModelConfig(Struct, omit_defaults=True):
video_in: bool = False
video_out: bool = False
cost_factor: float = 1.0
preset: str = ""
url: str = ""
# DeepSeek API surface override ("" = inherit provider convention).
convention: DeepSeekConvention = ""
class HttpTimeoutConfig(Struct, omit_defaults=True):
@@ -187,6 +222,7 @@ def _default_openai_provider_tools() -> list[dict[str, Any]]:
class OpenAIProvider(ProviderConfig, tag="openai"):
"""OpenAI platform provider (agent uses Responses API).
``url`` should include the API version path (default: ``https://api.openai.com/v1``).
``provider_tools`` are hosted/provider-side tools (e.g. web_search) passed
through to ``POST /responses`` as opaque dicts. They are **not** local
``ToolRegistry`` handlers and are ignored by non-OpenAI clients.
@@ -218,9 +254,20 @@ def _default_deepseek_models() -> dict[str, ModelConfig]:
class DeepseekProvider(ProviderConfig, tag="deepseek"):
"""Deepseek API provider with optional extras (e.g. convention)."""
"""DeepSeek API provider.
``convention`` selects the API surface on DeepSeek's endpoints:
- ``""`` / ``"openai"`` → OpenAI-compatible chat completions (default)
- ``"responses"`` → OpenAI Responses API (``POST /responses``)
- ``"anthropic"`` → Anthropic Messages API (``POST /messages`` on
``https://api.deepseek.com/anthropic``)
``extras`` keeps arbitrary provider keys; the legacy ``extras.convention``
key is parsed but ignored (prefer the typed ``convention`` field).
"""
models: dict[str, ModelConfig] = field(default_factory=_default_deepseek_models)
convention: DeepSeekConvention = ""
extras: dict[str, str] = field(default_factory=dict)
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, cast
from .models import (
AnthropicProvider,
DeepseekProvider,
HttpTimeoutConfig,
ModelConfig,
OpenAICompatibleProvider,
OpenAIProvider,
Provider,
)
if TYPE_CHECKING:
from collections.abc import Mapping
type ProviderPreset = Literal["openai", "openai-compatible", "anthropic", "deepseek"]
DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL = "https://api.deepseek.com/anthropic"
@dataclass(frozen=True, slots=True)
class EffectiveProvider:
"""Provider routing after applying model-level ``preset`` / ``url`` overrides."""
preset: ProviderPreset
access_key_or_token: str
url: str
model: str | None
timeout: float | HttpTimeoutConfig | None
provider_tools: list[dict[str, object]]
source: Provider
# DeepSeek API surface (see DeepSeekConvention): "openai" / "anthropic" /
# "responses" for deepseek presets; "" (unused) for other presets.
convention: str = ""
def provider_preset(provider: Provider) -> ProviderPreset:
"""Return the tagged-union preset for *provider*."""
if isinstance(provider, OpenAIProvider):
return "openai"
if isinstance(provider, OpenAICompatibleProvider):
return "openai-compatible"
if isinstance(provider, AnthropicProvider):
return "anthropic"
return "deepseek"
def default_url_for_preset(preset: ProviderPreset) -> str:
"""Default base URL; includes the API version path when applicable."""
if preset == "openai":
return "https://api.openai.com/v1"
if preset == "anthropic":
return "https://api.anthropic.com/v1"
if preset == "deepseek":
return DEFAULT_DEEPSEEK_BASE_URL
# Generic OpenAI-compatible providers usually require a host-specific URL.
return ""
def _default_url_for_deepseek_convention(convention: str) -> str:
"""Default DeepSeek base URL for an API convention.
The Anthropic-convention endpoint lives under ``/anthropic``; chat and
Responses share the plain ``https://api.deepseek.com`` base.
"""
if convention == "anthropic":
return DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
return DEFAULT_DEEPSEEK_BASE_URL
def validate_model_preset(preset: str) -> ProviderPreset:
if preset in {"openai", "openai-compatible", "anthropic", "deepseek"}:
return cast("ProviderPreset", preset)
msg = f"unsupported model preset {preset!r}"
raise ValueError(msg)
def model_config_for(provider: Provider, model: str | None) -> ModelConfig | None:
if not model:
return None
return provider.models.get(model)
def resolve_effective_provider(provider: Provider, *, model: str | None = None) -> EffectiveProvider:
"""Apply model-level preset/url overrides to *provider*.
``preset`` always decides API conventions: ``openai`` means Responses,
``openai-compatible`` means Chat Completions, ``anthropic`` means Messages.
Exception: a ``deepseek`` preset may pick the Responses or Anthropic
surface via the ``convention`` field (``"responses"`` / ``"anthropic"``);
model-level ``convention`` wins over the provider-level one.
"""
parent_preset = provider_preset(provider)
model_cfg = model_config_for(provider, model)
if model_cfg is not None and model_cfg.preset.strip():
preset = validate_model_preset(model_cfg.preset.strip())
else:
preset = parent_preset
# DeepSeek API surface: model override > provider convention > chat default.
convention = ""
if preset == "deepseek" and isinstance(provider, DeepseekProvider):
convention = provider.convention or "openai"
if model_cfg is not None and model_cfg.convention:
convention = model_cfg.convention
url = model_cfg.url.strip() if model_cfg is not None and model_cfg.url.strip() else provider.url.strip()
if not url:
if preset == "deepseek":
url = _default_url_for_deepseek_convention(convention)
else:
url = default_url_for_preset(preset)
provider_tools: list[dict[str, object]] = []
if preset == "openai" and isinstance(provider, OpenAIProvider) and parent_preset == "openai":
provider_tools = [dict(item) for item in provider.provider_tools]
return EffectiveProvider(
preset=preset,
access_key_or_token=provider.access_key_or_token,
url=url,
model=model,
timeout=provider.timeout,
provider_tools=provider_tools,
source=provider,
convention=convention,
)
def effective_provider_to_config_dict(effective: EffectiveProvider) -> Mapping[str, object]:
"""Debug/status-friendly dict for an effective provider."""
return {
"preset": effective.preset,
"access_key_or_token": effective.access_key_or_token,
"url": effective.url,
"model": effective.model or "",
"timeout": effective.timeout,
"provider_tools": effective.provider_tools,
"convention": effective.convention,
}
+36 -1
View File
@@ -6,7 +6,14 @@ from typing import TYPE_CHECKING, Any, cast
import msgspec
import tomlkit
from .models import AgentConfig, DatabaseConfig, ModelConfig, PluginsConfig, Provider
from .models import (
AgentConfig,
DatabaseConfig,
ModelConfig,
NetworkingConfig,
PluginsConfig,
Provider,
)
if TYPE_CHECKING:
from collections.abc import Mapping, MutableMapping, Sequence
@@ -41,6 +48,14 @@ def _parse_plugins(raw: dict[str, object]) -> PluginsConfig:
return PluginsConfig()
def _parse_networking(raw: dict[str, object]) -> NetworkingConfig:
"""Parse the ``[networking]`` section, falling back to defaults."""
try:
return msgspec.convert(raw, NetworkingConfig)
except msgspec.ValidationError:
return NetworkingConfig()
def _parse_providers(
document: tomlkit.TOMLDocument,
) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]:
@@ -109,6 +124,7 @@ class ConfigStore:
_database: DatabaseConfig
_agent: AgentConfig
_plugins: PluginsConfig
_networking: NetworkingConfig
_providers: dict[str, Provider]
_bad_providers: dict[str, object]
_recoverable_providers: dict[str, Provider]
@@ -120,6 +136,7 @@ class ConfigStore:
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
@property
@@ -158,6 +175,18 @@ class ConfigStore:
"""Typed plugin allowlist (enable / disable entry-point names)."""
return self._plugins
# -- networking (read-only) --
@property
def networking(self) -> MappingProxyType[str, object]:
"""Read-only mapping view of networking / SSRF-related configuration."""
return MappingProxyType(msgspec.structs.asdict(self._networking))
@property
def networking_config(self) -> NetworkingConfig:
"""Typed networking section (e.g. Fake-IP SSRF exemptions)."""
return self._networking
def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig:
"""Replace the plugin enable list (in-memory; call :meth:`write` to persist)."""
cleaned = [name.strip() for name in names if name.strip()]
@@ -360,6 +389,7 @@ class ConfigStore:
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {})))
self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {})))
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
# -- internal sync helpers --
@@ -413,6 +443,10 @@ class ConfigStore:
"""Sync ``[plugins]`` to the document."""
self._sync_section("plugins", self._plugins)
def _sync_networking_section(self) -> None:
"""Sync ``[networking]`` to the document."""
self._sync_section("networking", self._networking)
@staticmethod
def _to_inline_table(mapping: Mapping[str, object]) -> object:
"""Encode a flat (or nested-dict) mapping as a TOML inline table."""
@@ -471,4 +505,5 @@ class ConfigStore:
self._sync_database_section()
self._sync_agent_section()
self._sync_plugins_section()
self._sync_networking_section()
self._sync_providers_section()
@@ -0,0 +1,2 @@
from .client import AnthropicClient as AnthropicClient
from .config import AnthropicConfig as AnthropicConfig
+130
View File
@@ -0,0 +1,130 @@
"""Anthropic ``/messages`` HTTP client."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import msgspec
import niquests
from .model import (
AnthropicErrorEvent,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicMessageStop,
AnthropicModelsResponse,
AnthropicStreamEvent,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from niquests.async_session import AsyncSession
from .config import AnthropicConfig
class AnthropicClient:
"""Anthropic API client for ``POST /messages`` + ``GET /models``."""
kind: str = "messages"
_HTTP_ERROR: int = 400
session: AsyncSession
encoder: msgspec.json.Encoder
decoder: msgspec.json.Decoder[AnthropicMessageResponse]
stream_decoder: msgspec.json.Decoder[AnthropicStreamEvent]
models_decoder: msgspec.json.Decoder[AnthropicModelsResponse]
_api_key: str
_api_version: str
def __init__(self, config: AnthropicConfig) -> None:
self.session = niquests.AsyncSession(base_url=config.base_url, timeout=config.timeout)
self.encoder = msgspec.json.Encoder()
self.decoder = msgspec.json.Decoder(AnthropicMessageResponse)
self.stream_decoder = msgspec.json.Decoder(AnthropicStreamEvent)
self.models_decoder = msgspec.json.Decoder(AnthropicModelsResponse)
self._api_key = config.api_key
self._api_version = config.anthropic_version
def _headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
"x-api-key": self._api_key,
"anthropic-version": self._api_version,
}
async def models(self) -> list[str]:
resp = await self.session.get("/models", headers=self._headers(), stream=False)
body = await self._read_json_body(resp, what="models")
parsed = self.models_decoder.decode(body)
return [item.id for item in parsed.data if item.id]
@overload
async def messages(
self, param: AnthropicMessagesParam, *, stream: Literal[False] = False
) -> AnthropicMessageResponse: ...
@overload
async def messages(
self, param: AnthropicMessagesParam, *, stream: Literal[True]
) -> AsyncIterator[AnthropicStreamEvent]: ...
async def messages(
self, param: AnthropicMessagesParam, *, stream: bool = False
) -> AnthropicMessageResponse | AsyncIterator[AnthropicStreamEvent]:
param = msgspec.structs.replace(param, stream=stream)
data = self.encoder.encode(param)
if stream:
resp = await self.session.post("/messages", data=data, headers=self._headers(), stream=True)
return self._parse_sse(resp)
resp = await self.session.post("/messages", data=data, headers=self._headers(), stream=False)
body = await self._read_json_body(resp, what="messages")
return self.decoder.decode(body)
async def _ensure_ok(self, resp: object, *, what: str = "request") -> None:
from plyngent.lmproto.openai_compatible.client import http_error_message, read_response_body
status = getattr(resp, "status_code", None)
if not isinstance(status, int):
return
body: bytes | str | None = None
if status >= AnthropicClient._HTTP_ERROR:
body = await read_response_body(resp)
msg = http_error_message(status, body, what=what)
if msg is None:
return
raise RuntimeError(msg)
async def _read_json_body(self, resp: object, *, what: str) -> bytes:
from plyngent.lmproto.openai_compatible.client import read_response_body
await self._ensure_ok(resp, what=what)
body = await read_response_body(resp)
if body is None:
msg = f"{what} response body is empty"
raise RuntimeError(msg)
if not isinstance(body, (bytes, bytearray)):
msg = f"{what} response body has unexpected type {type(body)!r}"
raise TypeError(msg)
return bytes(body)
async def _parse_sse(self, resp: object) -> AsyncIterator[AnthropicStreamEvent]:
from plyngent.lmproto.openai_compatible.client import close_async_response, sse_data_payload
typed = cast("Any", resp)
try:
await self._ensure_ok(typed, what="messages")
async for raw in typed.iter_lines():
if not raw:
continue
parsed = sse_data_payload(bytes(raw))
if parsed is None:
continue
if parsed is False:
break
event = self.stream_decoder.decode(parsed)
yield event
if isinstance(event, (AnthropicMessageStop, AnthropicErrorEvent)):
break
finally:
await close_async_response(typed)
+22
View File
@@ -0,0 +1,22 @@
"""Anthropic API configuration."""
from __future__ import annotations
from dataclasses import dataclass
from plyngent.lmproto.openai_compatible.config import (
DEFAULT_HTTP_CONNECT_TIMEOUT,
DEFAULT_HTTP_READ_TIMEOUT,
HttpTimeout,
)
DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"
DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
@dataclass
class AnthropicConfig:
api_key: str
base_url: str = DEFAULT_ANTHROPIC_BASE_URL
anthropic_version: str = DEFAULT_ANTHROPIC_VERSION
timeout: HttpTimeout = (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT)
+173
View File
@@ -0,0 +1,173 @@
"""Anthropic ``/messages`` API msgspec models."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
from msgspec import UNSET, Struct, field
if TYPE_CHECKING:
from plyngent.typedef import Unset
class AnthropicTextContent(Struct, tag_field="type", tag="text"):
text: str
class AnthropicImageContent(Struct, tag_field="type", tag="image"):
source: dict[str, Any]
class AnthropicToolUseContent(Struct, tag_field="type", tag="tool_use"):
id: str
name: str
input: dict[str, Any]
class AnthropicToolResultContent(Struct, tag_field="type", tag="tool_result"):
tool_use_id: str
content: str | list[AnthropicTextContent | AnthropicImageContent]
is_error: bool = False
type AnthropicContentBlock = (
AnthropicTextContent | AnthropicImageContent | AnthropicToolUseContent | AnthropicToolResultContent
)
class AnthropicUserMessage(Struct, tag_field="role", tag="user"):
content: str | list[AnthropicTextContent | AnthropicImageContent | AnthropicToolResultContent]
class AnthropicAssistantMessage(Struct, tag_field="role", tag="assistant"):
content: str | list[AnthropicTextContent | AnthropicToolUseContent]
type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage
class AnthropicToolDefinition(Struct, omit_defaults=True):
name: str
description: str | Unset = UNSET
input_schema: dict[str, Any] | Unset = UNSET
class AnthropicToolChoice(Struct, omit_defaults=True):
type: Literal["auto", "any", "tool"] = "auto"
name: str | Unset = UNSET
disable_parallel_tool_use: bool | Unset = UNSET
class AnthropicMetadata(Struct, omit_defaults=True):
user_id: str | Unset = UNSET
class AnthropicMessagesParam(Struct, omit_defaults=True):
model: str
max_tokens: int = 8192
messages: list[AnthropicMessage] = field(default_factory=list)
system: str | list[AnthropicTextContent] | Unset = UNSET
tools: list[AnthropicToolDefinition] | Unset = UNSET
tool_choice: AnthropicToolChoice | Unset = UNSET
metadata: AnthropicMetadata | Unset = UNSET
stop_sequences: list[str] | Unset = UNSET
stream: bool = False
temperature: float | Unset = UNSET
top_p: float | Unset = UNSET
top_k: int | Unset = UNSET
class AnthropicUsage(Struct, omit_defaults=True):
input_tokens: int = 0
output_tokens: int = 0
cache_creation_input_tokens: int | None = None
cache_read_input_tokens: int | None = None
class AnthropicResponseText(Struct, tag_field="type", tag="text"):
text: str
class AnthropicResponseToolUse(Struct, tag_field="type", tag="tool_use"):
id: str
name: str
input: dict[str, Any]
type AnthropicResponseContent = AnthropicResponseText | AnthropicResponseToolUse
class AnthropicMessageResponse(Struct, omit_defaults=True):
id: str
type: str = "message"
role: str = "assistant"
content: list[AnthropicResponseContent] = field(default_factory=list)
model: str = ""
stop_reason: str | None = None
stop_sequence: str | None = None
usage: AnthropicUsage = field(default_factory=AnthropicUsage)
class AnthropicRawContentBlock(Struct, omit_defaults=True):
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
class AnthropicMessageStart(Struct, tag_field="type", tag="message_start"):
message: AnthropicMessageResponse
class AnthropicPing(Struct, tag_field="type", tag="ping"):
pass
class AnthropicContentBlockStart(Struct, tag_field="type", tag="content_block_start"):
index: int
content_block: AnthropicRawContentBlock
class AnthropicContentBlockDelta(Struct, tag_field="type", tag="content_block_delta"):
index: int
delta: AnthropicRawContentBlock
class AnthropicContentBlockStop(Struct, tag_field="type", tag="content_block_stop"):
index: int
class AnthropicMessageDelta(Struct, tag_field="type", tag="message_delta"):
delta: dict[str, Any]
usage: AnthropicUsage
class AnthropicMessageStop(Struct, tag_field="type", tag="message_stop"):
pass
class AnthropicErrorEvent(Struct, tag_field="type", tag="error"):
error: dict[str, Any]
type AnthropicStreamEvent = (
AnthropicMessageStart
| AnthropicPing
| AnthropicContentBlockStart
| AnthropicContentBlockDelta
| AnthropicContentBlockStop
| AnthropicMessageDelta
| AnthropicMessageStop
| AnthropicErrorEvent
)
class AnthropicModelInfo(Struct, omit_defaults=True):
type: str = ""
id: str = ""
class AnthropicModelsResponse(Struct, omit_defaults=True):
data: list[AnthropicModelInfo] = field(default_factory=list)
@@ -1,3 +1,4 @@
from .anthropic.client import DeepseekAnthropicClient as DeepseekAnthropicClient
from .openai_compat.client import DeepseekOpenAIClient as DeepseekOpenAIClient
from .openai_compat.model import AnyChatMessage as AnyChatMessage
from .openai_compat.model import AssistantChatMessage as AssistantChatMessage
@@ -6,3 +7,4 @@ from .openai_compat.model import DeepSeekReasoningEffort as DeepSeekReasoningEff
from .openai_compat.model import NamedChatMessage as NamedChatMessage
from .openai_compat.model import ThinkingOptions as ThinkingOptions
from .openai_compat.model import ToolChatMessage as ToolChatMessage
from .responses.client import DeepseekResponsesClient as DeepseekResponsesClient
@@ -0,0 +1 @@
from .client import DeepseekAnthropicClient as DeepseekAnthropicClient
@@ -0,0 +1,35 @@
"""DeepSeek Anthropic Messages client (``POST /messages`` on the DeepSeek endpoint)."""
from __future__ import annotations
from typing import TYPE_CHECKING, override
from plyngent.lmproto.anthropic.client import AnthropicClient
if TYPE_CHECKING:
from plyngent.lmproto.anthropic.config import AnthropicConfig
class DeepseekAnthropicClient(AnthropicClient):
"""Anthropic Messages client pointed at DeepSeek's Anthropic-compatible endpoint.
DeepSeek exposes the Anthropic API format at ``https://api.deepseek.com/anthropic``
(see https://api-docs.deepseek.com/guides/anthropic_api). ``x-api-key`` is fully
supported; ``anthropic-version`` / ``anthropic-beta`` headers are ignored by the
server but harmless. Model ids map server-side: ``claude-opus*`` → ``deepseek-v4-pro``,
``claude-haiku*`` / ``claude-sonnet*`` → ``deepseek-v4-flash``, unknown names →
``deepseek-v4-flash``; real DeepSeek ids pass through as-is.
``GET /models`` is not documented on the ``/anthropic`` base, so ``models()``
returns an empty list and model selection stays config-driven.
"""
kind: str = "messages"
def __init__(self, config: AnthropicConfig) -> None:
super().__init__(config)
@override
async def models(self) -> list[str]:
"""No documented ``GET /models`` on DeepSeek's ``/anthropic`` base."""
return []
@@ -1,8 +1,12 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Literal, overload
import msgspec
from ...openai_compatible.client import BaseOpenAIClient, read_response_body
from ...openai_compatible.compat import coerce_chat_completions_param_any
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -12,7 +16,22 @@ if TYPE_CHECKING:
from .model import ChatCompletionsParam
def _inject_thinking(data: bytes) -> bytes:
"""Inject ``thinking: {type: "enabled"}`` into the encoded request body.
DeepSeek's reasoning model requires the ``thinking`` parameter to be
explicitly set (default is ``enabled``). The agent loop constructs the
base ``ChatCompletionsParam`` which lacks this field, so we add it
after msgspec encoding.
"""
body = json.loads(data)
body["thinking"] = {"type": "enabled"}
return json.dumps(body, separators=(",", ":")).encode("utf-8")
class DeepseekOpenAIClient(BaseOpenAIClient):
kind: str = "chat_completions"
def __init__(self, config: OpenAIConfig) -> None:
super().__init__(config)
@@ -29,8 +48,8 @@ class DeepseekOpenAIClient(BaseOpenAIClient):
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
param = msgspec.structs.replace(param, stream=stream)
data = self.encoder.encode(param)
param = coerce_chat_completions_param_any(msgspec.structs.replace(param, stream=stream))
data = _inject_thinking(self.encoder.encode(param))
if stream:
resp = await self.session.post(
"/chat/completions",
@@ -0,0 +1 @@
from .client import DeepseekResponsesClient as DeepseekResponsesClient
@@ -0,0 +1,58 @@
"""DeepSeek Responses API client (``POST /responses`` on the DeepSeek endpoint)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast, override
from plyngent.lmproto.openai.client import OpenAIClient
# DeepSeek Responses SSE streams end with a terminal event instead of
# ``data: [DONE]`` (see https://api-docs.deepseek.com/guides/responses_api).
_RESPONSES_TERMINAL_EVENTS = frozenset({"response.completed", "response.incomplete", "response.failed"})
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from plyngent.lmproto.openai.model import ResponseStreamEvent
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
class DeepseekResponsesClient(OpenAIClient):
"""DeepSeek Responses client: OpenAI Responses format on DeepSeek's endpoint.
Differs from :class:`~plyngent.lmproto.openai.OpenAIClient` only in
streaming: DeepSeek sends no ``data: [DONE]``, so the SSE loop stops at a
terminal event (``response.completed`` / ``response.incomplete`` /
``response.failed``) instead of waiting for ``[DONE]``.
"""
kind: str = "responses"
def __init__(self, config: OpenAIConfig) -> None:
super().__init__(config)
@override
async def _parse_response_sse(self, resp: object) -> AsyncIterator[ResponseStreamEvent]:
"""Yield Responses API SSE events; stop at terminal events (no ``[DONE]``)."""
from plyngent.lmproto.openai_compatible.client import (
close_async_response,
sse_data_payload,
)
typed = cast("Any", resp)
try:
await self._ensure_ok(typed, what="responses")
async for raw in typed.iter_lines():
if not raw:
continue
parsed = sse_data_payload(bytes(raw))
if parsed is None:
continue
if parsed is False:
break
event = self.response_event_decoder.decode(parsed)
yield event
if event.type in _RESPONSES_TERMINAL_EVENTS:
break
finally:
await close_async_response(typed)
+2
View File
@@ -23,6 +23,8 @@ if TYPE_CHECKING:
class OpenAIClient(BaseOpenAIClient):
"""Full OpenAI HTTP client (``/chat/completions`` + ``/responses`` + ``/models``)."""
kind: str = "responses"
response_decoder: msgspec.json.Decoder[Response]
response_deleted_decoder: msgspec.json.Decoder[ResponseDeleted]
response_event_decoder: msgspec.json.Decoder[ResponseStreamEvent]
+3
View File
@@ -282,6 +282,9 @@ class ResponseStreamEvent(Struct, omit_defaults=True):
name: str | Unset = UNSET
arguments: str | Unset = UNSET
call_id: str | Unset = UNSET
# error events (type == "error")
code: str | Unset = UNSET
message: str | Unset = UNSET
# --- Helpers -----------------------------------------------------------------------
@@ -8,6 +8,7 @@ import niquests
from msgspec import UNSET
from niquests.auth import BearerTokenAuth
from .compat import coerce_chat_completions_param
from .config import OpenAIConfig # noqa: TC001
from .model import (
AssistantFunctionTool,
@@ -179,6 +180,8 @@ class BaseOpenAIClient:
class OpenAICompatibleClient(BaseOpenAIClient):
"""Chat Completions only (``POST /chat/completions`` + ``GET /models``)."""
kind: str = "chat_completions"
def __init__(self, config: OpenAIConfig) -> None:
super().__init__(config)
@@ -196,7 +199,7 @@ class OpenAICompatibleClient(BaseOpenAIClient):
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
# Library pattern: async def returns AsyncIterator when stream=True.
param = msgspec.structs.replace(param, stream=stream)
param = coerce_chat_completions_param(msgspec.structs.replace(param, stream=stream))
data = self.encoder.encode(param)
if stream:
resp = await self.session.post(
@@ -0,0 +1,62 @@
"""Message normalization for chat-completions-compatible wire APIs."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import msgspec
from .model import DeveloperChatMessage, SystemChatMessage
if TYPE_CHECKING:
from collections.abc import Sequence
from .model import AnyChatMessage, ChatCompletionsParam
def developer_to_system_message(message: DeveloperChatMessage) -> SystemChatMessage:
"""Convert an internal developer-role message to classic chat ``system`` role."""
return SystemChatMessage(content=message.content, name=message.name)
def coerce_developer_messages_to_system(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
"""Return *messages* with ``developer`` roles rewritten as ``system``.
Most OpenAI-compatible ``/chat/completions`` providers only accept
system/user/assistant/tool. Internal developer checkpoints still carry
control-plane intent best as ``system`` on that wire protocol.
"""
changed = False
out: list[AnyChatMessage] = []
for message in messages:
if isinstance(message, DeveloperChatMessage):
out.append(developer_to_system_message(message))
changed = True
else:
out.append(message)
return out if changed else list(messages)
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)
if messages == param.messages:
return param
return msgspec.structs.replace(param, messages=messages)
def coerce_chat_completions_param_any(param: Any) -> Any:
"""Generic variant for provider-specific chat param structs.
DeepSeek's request type excludes ``DeveloperChatMessage`` from its annotation,
but callers can still pass the generic agent param shape at runtime. Replace
messages structurally before encoding without depending on the exact class.
"""
messages_obj: Any = getattr(param, "messages", None)
if not isinstance(messages_obj, list):
return param
messages = cast("list[AnyChatMessage]", messages_obj)
coerced = coerce_developer_messages_to_system(messages)
if coerced == messages:
return param
return msgspec.structs.replace(param, messages=coerced)
+60 -27
View File
@@ -15,7 +15,9 @@ from .engine import create_engine
from .schema import Message, PlyngentBase, Session, User
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
type MigrationStep = Callable[[object, set[str]], None]
DEFAULT_USER_NAME = "local"
@@ -63,13 +65,17 @@ class MemoryStore:
return store
async def create_schema(self) -> None:
"""Create all tables if they do not exist; apply lightweight migrations."""
"""Create all tables if they do not exist; apply versioned migrations.
Schema evolution is grouped by release: ordered steps
(:data:`_SCHEMA_MIGRATIONS`) run only when the stored schema version
(:attr:`PRAGMA user_version`) is older, then the version is stamped.
Each step guards on its own columns, so legacy DBs that were upgraded
incrementally (user_version=0 but columns already present) stay safe.
"""
async with self._engine.begin() as conn:
_ = await conn.run_sync(PlyngentBase.metadata.create_all)
await conn.run_sync(_migrate_session_workspace)
await conn.run_sync(_migrate_session_llm)
await conn.run_sync(_migrate_session_todo_stack)
await conn.run_sync(_migrate_session_context_usage)
await conn.run_sync(_run_schema_migrations)
async def close(self) -> None:
"""Dispose the underlying engine."""
@@ -343,49 +349,45 @@ def _session_columns(sync_conn: object) -> set[str]:
return {str(row[1]) for row in rows}
def _migrate_session_workspace(sync_conn: object) -> None:
"""Add ``session.workspace`` on existing SQLite DBs created before the column existed."""
def _schema_version(sync_conn: object) -> int:
"""Read the SQLite ``user_version`` (0 = never migrated/stamped)."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return 0
return int(sync_conn.scalar(text("PRAGMA user_version")) or 0)
def _migrate_session_v1(sync_conn: object, columns: set[str]) -> None:
"""v0.1.0: add ``workspace`` / ``provider_name`` / ``model``."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
columns = _session_columns(sync_conn)
if "workspace" in columns:
return
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN workspace VARCHAR(1024)"))
def _migrate_session_llm(sync_conn: object) -> None:
"""Add ``session.provider_name`` / ``session.model`` for remembered LLM selection."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
columns = _session_columns(sync_conn)
if "workspace" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN workspace VARCHAR(1024)"))
if "provider_name" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN provider_name VARCHAR(128)"))
if "model" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN model VARCHAR(256)"))
def _migrate_session_todo_stack(sync_conn: object) -> None:
"""Add ``session.todo_stack`` JSON for the todo/task sub-task stack."""
def _migrate_session_v2(sync_conn: object, columns: set[str]) -> None:
"""v0.1.2: add ``todo_stack`` JSON for the todo/task sub-task stack."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
columns = _session_columns(sync_conn)
if "todo_stack" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN todo_stack JSON"))
def _migrate_session_context_usage(sync_conn: object) -> None:
"""Add session context usage + directive reminder band columns."""
def _migrate_session_v3(sync_conn: object, columns: set[str]) -> None:
"""Post-v0.2.0: add session context usage + directive reminder band columns."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
columns = _session_columns(sync_conn)
if "last_prompt_tokens" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN last_prompt_tokens INTEGER"))
if "peak_prompt_tokens" not in columns:
@@ -396,3 +398,34 @@ def _migrate_session_context_usage(sync_conn: object) -> None:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN usage_source VARCHAR(16)"))
if "reminder_last_band" not in columns:
_ = sync_conn.execute(text("ALTER TABLE session ADD COLUMN reminder_last_band INTEGER"))
# Latest schema version this build knows. Bump (and append a step to
# _SCHEMA_MIGRATIONS) when a new release adds or changes columns.
_SCHEMA_VERSION = 3
# Ordered (version, step) pairs. Each step receives the current ``session``
# column set (read once) and adds only its own missing columns, so it is
# idempotent for DBs that were upgraded incrementally without a version stamp.
# Version 1 shipped in v0.1.0, version 2 in v0.1.2, version 3 post-v0.2.0.
_SCHEMA_MIGRATIONS: tuple[tuple[int, MigrationStep], ...] = (
(1, _migrate_session_v1),
(2, _migrate_session_v2),
(3, _migrate_session_v3),
)
def _run_schema_migrations(sync_conn: object) -> None:
"""Run pending versioned ``session`` migrations, then stamp the version."""
from sqlalchemy.engine import Connection
if not isinstance(sync_conn, Connection):
return
version = _schema_version(sync_conn)
if version >= _SCHEMA_VERSION:
return
columns = _session_columns(sync_conn)
for step_version, step in _SCHEMA_MIGRATIONS:
if step_version > version:
step(sync_conn, columns)
_ = sync_conn.execute(text(f"PRAGMA user_version = {_SCHEMA_VERSION}"))
+77 -58
View File
@@ -3,14 +3,14 @@ from __future__ import annotations
import math
from typing import TYPE_CHECKING
from plyngent.config.models import (
DeepseekProvider,
HttpTimeoutConfig,
OpenAICompatibleProvider,
OpenAIProvider,
Provider,
from plyngent.config.routing import EffectiveProvider, resolve_effective_provider
from plyngent.lmproto.anthropic import AnthropicClient
from plyngent.lmproto.anthropic.config import AnthropicConfig as AnthropicConfigCls
from plyngent.lmproto.deepseek import (
DeepseekAnthropicClient,
DeepseekOpenAIClient,
DeepseekResponsesClient,
)
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
from plyngent.lmproto.openai import OpenAIClient
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig
from plyngent.lmproto.openai_compatible.config import (
@@ -20,14 +20,20 @@ from plyngent.lmproto.openai_compatible.config import (
)
if TYPE_CHECKING:
from collections.abc import Mapping
from plyngent.agent.responses_client import ResponsesChatClient
from plyngent.config.models import HttpTimeoutConfig, Provider
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"
DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
type ProtocolClient = OpenAIClient | OpenAICompatibleClient | DeepseekOpenAIClient | ResponsesChatClient
type ProtocolClient = (
OpenAIClient
| OpenAICompatibleClient
| DeepseekOpenAIClient
| DeepseekResponsesClient
| DeepseekAnthropicClient
| AnthropicClient
)
# Backward-compatible name used by older imports/tests.
type OpenAICompatibleClientUnion = ProtocolClient
@@ -62,7 +68,6 @@ def normalize_http_timeout(timeout: float | HttpTimeoutConfig | None) -> HttpTim
raise InvalidHttpTimeoutError(msg)
return value
# Remaining union member: HttpTimeoutConfig
connect = DEFAULT_HTTP_CONNECT_TIMEOUT if timeout.connect is None else float(timeout.connect)
read = DEFAULT_HTTP_READ_TIMEOUT if timeout.read is None else float(timeout.read)
if not math.isfinite(connect) or connect <= 0:
@@ -74,60 +79,74 @@ def normalize_http_timeout(timeout: float | HttpTimeoutConfig | None) -> HttpTim
return (connect, read)
def provider_to_openai_config(provider: OpenAIProvider | OpenAICompatibleProvider | DeepseekProvider) -> OpenAIConfig:
"""Map a provider config entry to :class:`OpenAIConfig`."""
if isinstance(provider, OpenAIProvider):
base_url = provider.url or DEFAULT_OPENAI_BASE_URL
elif isinstance(provider, DeepseekProvider):
base_url = provider.url or DEFAULT_DEEPSEEK_BASE_URL
def provider_to_openai_config(
provider: Provider | EffectiveProvider,
*,
model: str | None = None,
) -> OpenAIConfig:
"""Map provider/effective config to the shared OpenAI-compatible HTTP config."""
if isinstance(provider, EffectiveProvider):
effective = provider
else:
base_url = provider.url
if not base_url:
msg = "openai-compatible provider requires a non-empty url"
raise ProviderNotSupportedError(msg)
effective = resolve_effective_provider(provider, model=model)
if not effective.url and effective.preset in {"openai-compatible", "deepseek"}:
msg = f"{effective.preset} provider requires a non-empty url"
raise ProviderNotSupportedError(msg)
try:
http_timeout = normalize_http_timeout(provider.timeout)
http_timeout = normalize_http_timeout(effective.timeout)
except InvalidHttpTimeoutError as exc:
raise ProviderNotSupportedError(str(exc)) from exc
return OpenAIConfig(
access_key_or_token=provider.access_key_or_token,
base_url=base_url,
access_key_or_token=effective.access_key_or_token,
base_url=effective.url,
timeout=http_timeout,
)
def _deepseek_convention(extras: Mapping[str, str]) -> str:
return extras.get("convention", "openai").lower()
def create_client(provider: Provider) -> ProtocolClient:
"""Build a protocol client for the given provider config entry.
OpenAI platform providers are wrapped so the agent uses the Responses API
while still exposing a chat-completions-shaped interface.
Raises:
ProviderNotSupportedError: When the provider preset (or DeepSeek convention)
has no implemented client yet.
"""
if isinstance(provider, OpenAIProvider):
from plyngent.agent.responses_client import wrap_openai_for_agent
return wrap_openai_for_agent(
OpenAIClient(provider_to_openai_config(provider)),
provider_tools=provider.provider_tools or None,
def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient:
"""Build an Anthropic Messages client (real or DeepSeek convention) for *effective*."""
try:
http_timeout = normalize_http_timeout(effective.timeout)
except InvalidHttpTimeoutError as exc:
raise ProviderNotSupportedError(str(exc)) from exc
if effective.preset == "deepseek":
return DeepseekAnthropicClient(
AnthropicConfigCls(
api_key=effective.access_key_or_token,
base_url=effective.url or DEFAULT_DEEPSEEK_BASE_URL,
timeout=http_timeout,
)
)
if isinstance(provider, OpenAICompatibleProvider):
return OpenAICompatibleClient(provider_to_openai_config(provider))
if isinstance(provider, DeepseekProvider):
convention = _deepseek_convention(provider.extras)
if convention in {"openai", "openai_compat", "openai-compatible"}:
return DeepseekOpenAIClient(provider_to_openai_config(provider))
if convention == "anthropic":
msg = "deepseek anthropic convention is not implemented"
raise ProviderNotSupportedError(msg)
msg = f"unknown deepseek convention {convention!r}"
raise ProviderNotSupportedError(msg)
# Remaining Provider variant: AnthropicProvider
msg = "anthropic provider client is not implemented"
return AnthropicClient(
AnthropicConfigCls(
api_key=effective.access_key_or_token,
base_url=effective.url or DEFAULT_ANTHROPIC_BASE_URL,
timeout=http_timeout,
)
)
def create_client(provider: Provider, *, model: str | None = None) -> ProtocolClient:
"""Build a protocol client for *provider*, applying model-level routing.
``preset`` always decides API conventions: ``openai`` → /responses,
``openai-compatible`` → /chat/completions, ``anthropic`` → /messages.
Exception: a ``deepseek`` preset picks the Responses or Anthropic surface
when its effective ``convention`` is ``"responses"`` / ``"anthropic"``,
else chat completions.
"""
effective = resolve_effective_provider(provider, model=model)
if effective.preset == "openai":
return OpenAIClient(provider_to_openai_config(effective))
if effective.preset == "openai-compatible":
return OpenAICompatibleClient(provider_to_openai_config(effective))
if effective.preset == "deepseek":
if effective.convention == "responses":
return DeepseekResponsesClient(provider_to_openai_config(effective))
if effective.convention == "anthropic":
return _anthropic_client(effective)
return DeepseekOpenAIClient(provider_to_openai_config(effective))
if effective.preset == "anthropic":
return _anthropic_client(effective)
msg = f"provider preset {effective.preset!r} is not supported"
raise ProviderNotSupportedError(msg)
+2
View File
@@ -30,9 +30,11 @@ from .file import tree as tree
from .file import write_file as write_file
from .net import NET_TOOLS as NET_TOOLS
from .net import clear_private_grants as clear_private_grants
from .net import clear_ssrf_assume_public_cidrs as clear_ssrf_assume_public_cidrs
from .net import fetch as fetch
from .net import grant_private_host as grant_private_host
from .net import set_fetch_policy_confirm_hook as set_fetch_policy_confirm_hook
from .net import set_ssrf_assume_public_cidrs as set_ssrf_assume_public_cidrs
from .plugins import load_plugin_tools as load_plugin_tools
from .process import PROCESS_TOOLS as PROCESS_TOOLS
from .process import ask_into_pty as ask_into_pty
+10 -20
View File
@@ -1,27 +1,17 @@
from __future__ import annotations
import json
from typing import cast
from plyngent.agent import ToolTag, tool
from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async
def parse_options(raw: str) -> list[ChoiceOption]:
"""Parse options JSON: list of strings or objects with label/description/value."""
text = raw.strip()
if not text:
def parse_options(options: list[object]) -> list[ChoiceOption]:
"""Normalize a list of strings or dicts into ``ChoiceOption`` list."""
if not options:
return []
try:
data: object = json.loads(text)
except json.JSONDecodeError as exc:
msg = f"options must be JSON: {exc}"
raise ValueError(msg) from exc
if not isinstance(data, list):
msg = "options must be a JSON array"
raise TypeError(msg)
out: list[ChoiceOption] = []
for item_obj in cast("list[object]", data):
for item_obj in options:
if isinstance(item_obj, str):
out.append(ChoiceOption(label=item_obj))
continue
@@ -41,7 +31,7 @@ def parse_options(raw: str) -> list[ChoiceOption]:
)
)
continue
msg = "options items must be strings or objects"
msg = "options items must be strings or objects with label/description/value"
raise TypeError(msg)
return out
@@ -49,24 +39,24 @@ def parse_options(raw: str) -> list[ChoiceOption]:
@tool(name="ask_user_choice", tags=ToolTag.LOCAL)
async def choose_user(
question: str,
options: str,
options: list[str] | list[dict[str, object]],
default: str = "",
*,
allow_custom: bool = True,
) -> str:
"""Ask the human to pick from a list of options (or type a custom answer).
``options`` is a JSON array of strings, or objects with
``label``, optional ``description``, optional ``value``.
``options`` is a list of strings, or objects with
``label`` (required), optional ``description``, optional ``value``.
When ``allow_custom`` is true (default), free-text answers are accepted.
Returns the chosen option value (or custom text).
"""
try:
parsed = parse_options(options)
parsed = parse_options([cast("object", o) for o in options])
except (TypeError, ValueError) as exc:
return f"error: {exc}"
if not parsed and not allow_custom:
return "error: options must be a non-empty JSON array when allow_custom is false"
return "error: options must be a non-empty list when allow_custom is false"
try:
return await choose_async(
question,
+20 -22
View File
@@ -8,25 +8,14 @@ from plyngent.prompting import FormField, NonInteractiveError, form_async
from plyngent.tools.chat.choose import parse_options
def parse_fields(raw: str) -> list[FormField]:
"""Parse form fields JSON array of objects."""
text = raw.strip()
if not text:
return []
try:
data: object = json.loads(text)
except json.JSONDecodeError as exc:
msg = f"fields must be JSON: {exc}"
raise ValueError(msg) from exc
if not isinstance(data, list) or not data:
msg = "fields must be a non-empty JSON array"
def parse_fields(fields: list[dict[str, object]]) -> list[FormField]:
"""Normalize a list of field dicts into ``FormField`` list."""
if not fields:
msg = "fields must be a non-empty list"
raise ValueError(msg)
out: list[FormField] = []
for item_obj in cast("list[object]", data):
if not isinstance(item_obj, dict):
msg = "each field must be a JSON object"
raise TypeError(msg)
raw_map = {str(key): value for key, value in cast("dict[object, object]", item_obj).items()}
for item_obj in fields:
raw_map = {str(key): value for key, value in item_obj.items()}
name = raw_map.get("name")
prompt = raw_map.get("prompt")
if not isinstance(name, str) or not name:
@@ -39,7 +28,10 @@ def parse_fields(raw: str) -> list[FormField]:
options_raw = raw_map.get("options")
options = None
if options_raw is not None:
options = parse_options(json.dumps(options_raw))
if not isinstance(options_raw, list):
msg = f"field {name!r} options must be a list"
raise TypeError(msg)
options = parse_options(cast("list[object]", options_raw))
allow_custom_obj = raw_map.get("allow_custom", True)
allow_custom = allow_custom_obj if isinstance(allow_custom_obj, bool) else True
out.append(
@@ -55,12 +47,18 @@ def parse_fields(raw: str) -> list[FormField]:
@tool(name="ask_user_form", tags=ToolTag.LOCAL)
async def form_user(title: str, fields: str, *, confirm_submit: bool = True) -> str:
async def form_user(
title: str,
fields: list[dict[str, object]],
*,
confirm_submit: bool = True,
) -> str:
"""Run a multi-step form with the human; returns JSON object of answers.
``fields`` is a JSON array of objects:
``name``, ``prompt``, optional ``default``, optional ``options`` (same shape
as ask_user_choice), optional ``allow_custom`` (default true).
``fields`` is a list of objects:
``name`` (required), ``prompt`` (required), optional ``default``,
optional ``options`` (same shape as ask_user_choice),
optional ``allow_custom`` (default true).
When ``confirm_submit`` is true, the human reviews a summary before submit.
"""
try:
+34 -3
View File
@@ -2,12 +2,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolTag, invalidate_lineno_read, lineno_read_lines, tool
from plyngent.tools.workspace import resolve_path
if TYPE_CHECKING:
from pathlib import Path
# How many unread line numbers to preview in the error message.
_UNREAD_PREVIEW = 8
def _detect_newline(lines: list[str]) -> str:
for sample in lines:
@@ -48,6 +51,22 @@ def _validate_range(start_line: int, end_line: int, n: int) -> str | None:
return None
def _unread_lines_in_range(
read_lines: set[int],
start_line: int,
end_line: int,
n: int,
) -> list[int]:
"""Lines in ``start_line..end_line`` that were not read with line numbers.
Appending (``start_line == n + 1``) requires the last line to be read, so
line numbers stay truthful; the append point itself is not a line.
"""
if start_line == n + 1:
return [] if n in read_lines else [n]
return sorted(set(range(start_line, end_line + 1)) - read_lines)
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 [])
@@ -92,6 +111,18 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
if err is not None:
return err
unread = _unread_lines_in_range(lineno_read_lines(str(target)), start_line, end_line, len(lines))
if unread:
preview = unread[:_UNREAD_PREVIEW]
head = ", ".join(str(i) for i in preview)
if len(unread) > _UNREAD_PREVIEW:
head += f", … ({len(unread)} total)"
return f"error: lines {head} not read; call read_file(path, with_lineno=true) first to see current line numbers"
if start_line == len(lines) + 1:
return _append_after(target, path, text, lines, new_content)
return _replace_range(target, path, lines, start_line, end_line, new_content)
result = _append_after(target, path, text, lines, new_content)
else:
result = _replace_range(target, path, lines, start_line, end_line, new_content)
# Line numbers are stale after a write; force a re-read before next edit.
invalidate_lineno_read(str(target))
return result
+3 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.tools.workspace import resolve_path
@@ -54,4 +54,6 @@ async def edit_replace(path: str, old_string: str, new_string: str, max_replaces
n = min(max_replaces, found)
updated = text.replace(old_string, new_string, n)
_ = target.write_text(updated, encoding="utf-8")
# Any lineno read state is stale after a write; force a re-read.
invalidate_lineno_read(str(target))
return _success_message(path, replaced=n, found=found)
+8 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import shutil
from pathlib import Path
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
@@ -68,8 +68,12 @@ def _copy_or_move_validated(
else:
_ = shutil.copy2(source, dest, follow_symlinks=False)
label = _kind(source)
# A copy does not change the source, but the destination content is new.
invalidate_lineno_read(str(dest))
return f"copied {label} {src} -> {dest.relative_to(root)}"
moved = Path(shutil.move(str(source), str(dest))).resolve()
invalidate_lineno_read(str(source))
invalidate_lineno_read(str(moved))
return f"moved {_kind(moved)} {src} -> {moved.relative_to(root)}"
except WorkspaceError as exc:
return f"error: {exc}"
@@ -110,11 +114,13 @@ async def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
def _delete_directory(target: Path, path: str, *, recursive: bool) -> str:
if recursive:
shutil.rmtree(target)
invalidate_lineno_read(str(target))
return f"deleted directory {path} (recursive)"
try:
_ = next(target.iterdir())
except StopIteration:
target.rmdir()
invalidate_lineno_read(str(target))
return f"deleted empty directory {path}"
return f"error: directory not empty: {path} (set recursive=true)"
@@ -123,6 +129,7 @@ def _delete_target(target: Path, path: str, *, recursive: bool) -> str:
if target.is_symlink() or target.is_file():
kind = _kind(target)
target.unlink()
invalidate_lineno_read(str(target))
return f"deleted {kind} {path}"
if target.is_dir():
return _delete_directory(target, path, recursive=recursive)
+4 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolTag, mark_lineno_read, tool
from plyngent.tools.workspace import resolve_path
_LINENO_WIDTH = 6
@@ -29,7 +29,8 @@ async def read_file(
``offset`` is 0-based line start; ``limit`` is max lines (None = rest of file).
When ``with_lineno`` is true, each line is prefixed with its 1-based file line
number (`` N|…``), matching ``edit_lineno`` numbering.
number (`` N|…``), matching ``edit_lineno`` numbering, and those lines are
marked readable for ``edit_lineno`` this turn.
"""
target = resolve_path(path)
if not target.is_file():
@@ -44,5 +45,6 @@ async def read_file(
return ""
slice_lines = lines[start:end]
if with_lineno:
mark_lineno_read(str(target), set(range(start + 1, end + 1)))
return _format_with_lineno(slice_lines, start_lineno=start + 1)
return "".join(slice_lines)
+2 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolTag, invalidate_lineno_read, tool
from plyngent.tools.workspace import resolve_path
@@ -10,4 +10,5 @@ async def write_file(path: str, content: str) -> str:
target = resolve_path(path)
target.parent.mkdir(parents=True, exist_ok=True)
_ = target.write_text(content, encoding="utf-8")
invalidate_lineno_read(str(target))
return f"wrote {len(content)} characters to {path}"
+2
View File
@@ -2,6 +2,8 @@ from .fetch import fetch as fetch
from .grants import clear_private_grants as clear_private_grants
from .grants import grant_private_host as grant_private_host
from .grants import set_fetch_policy_confirm_hook as set_fetch_policy_confirm_hook
from .policy import clear_ssrf_assume_public_cidrs as clear_ssrf_assume_public_cidrs
from .policy import set_ssrf_assume_public_cidrs as set_ssrf_assume_public_cidrs
NET_TOOLS = [
fetch,
+68 -4
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import ipaddress
import socket
from contextvars import ContextVar
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING
@@ -44,6 +45,8 @@ _NEVER_ALLOW_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]
ipaddress.ip_network("fd00:ec2::254/128"), # AWS IMDS IPv6
)
type IpNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
class HostClass(Enum):
PUBLIC = "public"
@@ -55,6 +58,46 @@ class FetchPolicyError(ValueError):
"""Hard fetch policy violation (returned to the model as error text)."""
# Configured via ``[networking].ssrf_assume_public_cidrs`` (e.g. Clash Fake-IP).
_ssrf_assume_public_networks: ContextVar[tuple[IpNetwork, ...]] = ContextVar(
"plyngent_ssrf_assume_public_networks",
default=(),
)
def parse_ssrf_assume_public_cidrs(cidrs: Sequence[str] | None) -> tuple[IpNetwork, ...]:
"""Parse CIDR strings; invalid entries raise :class:`FetchPolicyError`."""
if not cidrs:
return ()
out: list[IpNetwork] = []
for raw in cidrs:
text = raw.strip()
if not text:
continue
try:
out.append(ipaddress.ip_network(text, strict=False))
except ValueError as exc:
msg = f"invalid ssrf_assume_public_cidrs entry {text!r}: {exc}"
raise FetchPolicyError(msg) from exc
return tuple(out)
def set_ssrf_assume_public_cidrs(cidrs: Sequence[str] | None) -> tuple[IpNetwork, ...]:
"""Install process/context networks treated as public for SSRF (config load)."""
networks = parse_ssrf_assume_public_cidrs(cidrs)
_ = _ssrf_assume_public_networks.set(networks)
return networks
def get_ssrf_assume_public_networks() -> tuple[IpNetwork, ...]:
return _ssrf_assume_public_networks.get()
def clear_ssrf_assume_public_cidrs() -> None:
"""Reset Fake-IP / assume-public exemptions (tests / chat exit)."""
_ = _ssrf_assume_public_networks.set(())
@dataclass(frozen=True, slots=True)
class ParsedFetchUrl:
"""Normalized URL pieces used for policy and the HTTP client."""
@@ -126,7 +169,15 @@ def resolve_redirect_url(current: str, location: str) -> str:
return parse_fetch_url(joined).url
def _ip_classification(address: str) -> HostClass:
def _ip_in_networks(ip: ipaddress.IPv4Address | ipaddress.IPv6Address, networks: Sequence[IpNetwork]) -> bool:
return any(ip in network for network in networks)
def _ip_classification(
address: str,
*,
assume_public: Sequence[IpNetwork] = (),
) -> HostClass:
try:
ip = ipaddress.ip_address(address)
except ValueError:
@@ -134,19 +185,32 @@ def _ip_classification(address: str) -> HostClass:
for network in _NEVER_ALLOW_NETWORKS:
if ip in network:
return HostClass.FORBIDDEN
# Clash Fake-IP (etc.): treat configured synthetic pools as public for policy.
# Metadata above still wins; do not list real loopback here.
if assume_public and _ip_in_networks(ip, assume_public):
return HostClass.PUBLIC
# IPv6 unique-local / link-local / loopback / unspecified
if ip.is_loopback or ip.is_link_local or ip.is_private or ip.is_reserved or ip.is_multicast or ip.is_unspecified:
return HostClass.PRIVATE
return HostClass.PUBLIC
def classify_ip_strings(addresses: Sequence[str]) -> HostClass:
"""Worst-class wins: FORBIDDEN > PRIVATE > PUBLIC."""
def classify_ip_strings(
addresses: Sequence[str],
*,
assume_public: Sequence[IpNetwork] | None = None,
) -> HostClass:
"""Worst-class wins: FORBIDDEN > PRIVATE > PUBLIC.
*assume_public* defaults to the process context from
:func:`set_ssrf_assume_public_cidrs` (``[networking].ssrf_assume_public_cidrs``).
"""
if not addresses:
return HostClass.FORBIDDEN
networks = get_ssrf_assume_public_networks() if assume_public is None else tuple(assume_public)
worst = HostClass.PUBLIC
for addr in addresses:
kind = _ip_classification(addr)
kind = _ip_classification(addr, assume_public=networks)
if kind is HostClass.FORBIDDEN:
return HostClass.FORBIDDEN
if kind is HostClass.PRIVATE:
+11 -1
View File
@@ -12,8 +12,18 @@ def test_truncate_tool_result_long() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20)
assert "truncated" in out
assert "Truncated" in out
assert "20" in out
assert "30" in out
assert "configuration file" in out
def test_truncate_tool_result_no_hint() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20, hint=False)
assert out.startswith("a" * 20)
assert "truncated" in out
assert "configuration file" not in out
def test_estimate_message_chars() -> None:
+11 -1
View File
@@ -41,8 +41,18 @@ def test_truncate_tool_result_long() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20)
assert "truncated" in out
assert "Truncated" in out
assert "20" in out
assert "30" in out
assert "configuration file" in out
def test_truncate_tool_result_no_hint() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20, hint=False)
assert out.startswith("a" * 20)
assert "truncated" in out
assert "configuration file" not in out
def test_compact_shrinks_old_tool_results() -> None:
+32
View File
@@ -0,0 +1,32 @@
"""Tests for the canonical finish_reason vocabulary shared by provider bridges."""
from __future__ import annotations
import pytest
from plyngent.agent.finish_reason import chat_finish_reason
@pytest.mark.parametrize(
("raw", "has_tool_calls", "expected"),
[
(None, False, "stop"),
(None, True, "tool_calls"),
("", False, "stop"),
("end_turn", False, "stop"),
("stop_sequence", False, "stop"),
("completed", False, "stop"),
("stop", False, "stop"),
("max_tokens", False, "length"),
("max_output_tokens", False, "length"),
("length", False, "length"),
("content_filter", False, "content_filter"),
("refusal", False, "content_filter"),
("tool_use", False, "tool_calls"),
("tool_calls", False, "tool_calls"),
("Tool_Use", False, "tool_calls"),
("MAX_TOKENS", False, "length"),
],
)
def test_chat_finish_reason(raw: str | None, has_tool_calls: bool, expected: str) -> None:
assert chat_finish_reason(raw, has_tool_calls=has_tool_calls) == expected
+1 -1
View File
@@ -848,7 +848,7 @@ async def test_tool_result_char_budget() -> None:
tool_msgs = [m for m in messages if isinstance(m, ToolChatMessage)]
assert len(tool_msgs) == 1
assert tool_msgs[0].content.startswith("x" * 20)
assert "truncated" in tool_msgs[0].content
assert "Truncated" in tool_msgs[0].content
async def test_empty_completion_raises() -> None:
+200
View File
@@ -0,0 +1,200 @@
"""Tests for Anthropic Messages chat ↔ native conversion."""
from __future__ import annotations
from msgspec import UNSET
from plyngent.agent.messages_bridge import (
anthropic_response_to_assistant,
anthropic_response_to_chat_completion,
anthropic_stop_to_finish_reason,
anthropic_usage_to_dict,
chat_messages_to_anthropic,
chat_param_to_anthropic_param,
tool_items_to_anthropic_tools,
)
from plyngent.lmproto.anthropic.model import (
AnthropicAssistantMessage,
AnthropicMessageResponse,
AnthropicResponseText,
AnthropicResponseToolUse,
AnthropicToolResultContent,
AnthropicToolUseContent,
AnthropicUsage,
AnthropicUserMessage,
)
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionsParam,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
ToolFunction,
ToolFunctionItem,
UserChatMessage,
)
def test_tool_items_to_anthropic_tools() -> None:
items = [
ToolFunctionItem(
function=ToolFunction(
name="read_file",
description="Read a file",
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
)
)
]
tools = tool_items_to_anthropic_tools(items)
assert len(tools) == 1
assert tools[0].name == "read_file"
assert tools[0].input_schema is not UNSET
def test_chat_messages_system_and_tool_round() -> None:
messages = [
SystemChatMessage(content="You are helpful."),
UserChatMessage(content="hi"),
AssistantChatMessage(
content=None,
tool_calls=[
AssistantFunctionToolCall(
id="call_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
ToolChatMessage(content="file body", tool_call_id="call_1"),
]
system, items = chat_messages_to_anthropic(messages)
assert system == "You are helpful."
assert len(items) == 3
assert isinstance(items[0], AnthropicUserMessage)
assert items[0].content == "hi"
assert isinstance(items[1], AnthropicAssistantMessage)
assert isinstance(items[1].content, list)
assert any(isinstance(b, AnthropicToolUseContent) and b.id == "call_1" for b in items[1].content)
assert isinstance(items[2], AnthropicUserMessage)
assert isinstance(items[2].content, list)
assert isinstance(items[2].content[0], AnthropicToolResultContent)
assert items[2].content[0].tool_use_id == "call_1"
assert items[2].content[0].content == "file body"
def test_developer_becomes_user_prefix() -> None:
system, items = chat_messages_to_anthropic(
[
SystemChatMessage(content="persona"),
UserChatMessage(content="go"),
DeveloperChatMessage(content="checkpoint"),
]
)
assert system == "persona"
assert len(items) == 2
assert isinstance(items[1], AnthropicUserMessage)
assert items[1].content == "[developer] checkpoint"
def test_merge_consecutive_tool_results() -> None:
messages = [
UserChatMessage(content="go"),
AssistantChatMessage(
content=None,
tool_calls=[
AssistantFunctionToolCall(
id="c1",
function=AssistantFunctionTool(name="a", arguments="{}"),
),
AssistantFunctionToolCall(
id="c2",
function=AssistantFunctionTool(name="b", arguments="{}"),
),
],
),
ToolChatMessage(content="r1", tool_call_id="c1"),
ToolChatMessage(content="r2", tool_call_id="c2"),
]
_, items = chat_messages_to_anthropic(messages)
assert len(items) == 3 # user, assistant, one merged tool-result user
assert isinstance(items[2], AnthropicUserMessage)
assert isinstance(items[2].content, list)
assert len(items[2].content) == 2
def test_anthropic_response_to_assistant_with_tools() -> None:
response = AnthropicMessageResponse(
id="msg_1",
model="claude-test",
stop_reason="tool_use",
content=[
AnthropicResponseText(text="calling"),
AnthropicResponseToolUse(id="tu_1", name="add", input={"a": 1, "b": 2}),
],
usage=AnthropicUsage(input_tokens=12, output_tokens=4),
)
assistant = anthropic_response_to_assistant(response)
assert assistant.content == "calling"
assert assistant.tool_calls is not UNSET
assert len(assistant.tool_calls) == 1
call = assistant.tool_calls[0]
assert isinstance(call, AssistantFunctionToolCall)
assert call.id == "tu_1"
assert call.function.name == "add"
assert '"a":1' in call.function.arguments or '"a": 1' in call.function.arguments
def test_anthropic_response_to_chat_completion_usage() -> None:
response = AnthropicMessageResponse(
id="msg_1",
model="claude-test",
stop_reason="end_turn",
content=[AnthropicResponseText(text="done")],
usage=AnthropicUsage(input_tokens=9, output_tokens=3),
)
chat = anthropic_response_to_chat_completion(response)
assert chat.choices[0].message.content == "done"
assert chat.choices[0].finish_reason == "stop"
assert chat.usage is not UNSET
assert chat.usage["prompt_tokens"] == 9
assert chat.usage["completion_tokens"] == 3
def test_stop_reason_mapping() -> None:
assert anthropic_stop_to_finish_reason("end_turn", has_tool_calls=False) == "stop"
assert anthropic_stop_to_finish_reason("tool_use", has_tool_calls=True) == "tool_calls"
assert anthropic_stop_to_finish_reason("max_tokens", has_tool_calls=False) == "length"
assert anthropic_stop_to_finish_reason("refusal", has_tool_calls=False) == "content_filter"
assert anthropic_stop_to_finish_reason(None, has_tool_calls=True) == "tool_calls"
def test_usage_to_dict() -> None:
assert anthropic_usage_to_dict(AnthropicUsage(input_tokens=1, output_tokens=2)) == {
"prompt_tokens": 1,
"completion_tokens": 2,
"total_tokens": 3,
"input_tokens": 1,
"output_tokens": 2,
}
assert anthropic_usage_to_dict(AnthropicUsage()) is None
def test_chat_param_to_anthropic_param() -> None:
param = ChatCompletionsParam(
model="claude-test",
messages=[
SystemChatMessage(content="Be brief."),
UserChatMessage(content="hi"),
],
tools=[ToolFunctionItem(function=ToolFunction(name="f", description="d", parameters={"type": "object"}))],
temperature=0.2,
max_completion_tokens=1024,
)
body = chat_param_to_anthropic_param(param)
assert body.model == "claude-test"
assert body.max_tokens == 1024
assert body.system == "Be brief."
assert body.tools is not UNSET
assert len(body.tools) == 1
assert body.temperature == 0.2
+352
View File
@@ -0,0 +1,352 @@
"""Tests for kind-based Anthropic Messages dispatch in the agent loop."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
from msgspec import UNSET
from plyngent.agent import (
AssistantMessageEvent,
TextDeltaEvent,
ToolCallEvent,
ToolRegistry,
ToolResultEvent,
UsageEvent,
run_chat_loop,
tool,
)
from plyngent.agent.messages_dispatch import dispatch_messages
from plyngent.lmproto.anthropic.model import (
AnthropicContentBlockDelta,
AnthropicContentBlockStart,
AnthropicMessageDelta,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicMessageStart,
AnthropicMessageStop,
AnthropicRawContentBlock,
AnthropicResponseText,
AnthropicResponseToolUse,
AnthropicStreamEvent,
AnthropicToolResultContent,
AnthropicUsage,
AnthropicUserMessage,
)
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
ChatCompletionsParam,
UserChatMessage,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
def _message_response(
*,
text: str = "hello",
tool_uses: list[tuple[str, str, dict[str, Any]]] | None = None,
stop_reason: str = "end_turn",
usage: AnthropicUsage | None = None,
) -> AnthropicMessageResponse:
content: list[Any] = []
if text:
content.append(AnthropicResponseText(text=text))
if tool_uses:
for call_id, name, inp in tool_uses:
content.append(AnthropicResponseToolUse(id=call_id, name=name, input=inp))
stop_reason = "tool_use"
return AnthropicMessageResponse(
id="msg_test",
model="claude-test",
content=content,
stop_reason=stop_reason,
usage=usage or AnthropicUsage(input_tokens=10, output_tokens=5),
)
class ScriptedMessagesClient:
"""Fake AnthropicClient that scripts ``messages()`` results."""
kind: str = "messages"
calls: list[AnthropicMessagesParam]
_non_stream: list[AnthropicMessageResponse]
_stream_events: list[list[AnthropicStreamEvent]]
def __init__(
self,
*,
non_stream: Sequence[AnthropicMessageResponse] | None = None,
stream_events: Sequence[Sequence[AnthropicStreamEvent]] | None = None,
) -> None:
self.calls = []
self._non_stream = list(non_stream or ())
self._stream_events = [list(events) for events in (stream_events or ())]
@overload
async def messages(
self, param: AnthropicMessagesParam, *, stream: Literal[False] = False
) -> AnthropicMessageResponse: ...
@overload
async def messages(
self, param: AnthropicMessagesParam, *, stream: Literal[True]
) -> AsyncIterator[AnthropicStreamEvent]: ...
async def messages(
self, param: AnthropicMessagesParam, *, stream: bool = False
) -> AnthropicMessageResponse | AsyncIterator[AnthropicStreamEvent]:
self.calls.append(param)
if stream:
if not self._stream_events:
msg = "no more scripted stream events"
raise RuntimeError(msg)
events = self._stream_events.pop(0)
async def _gen() -> AsyncIterator[AnthropicStreamEvent]:
for event in events:
yield event
return _gen()
if not self._non_stream:
msg = "no more scripted messages"
raise RuntimeError(msg)
return self._non_stream.pop(0)
async def test_dispatch_messages_non_stream_text() -> None:
client = ScriptedMessagesClient(non_stream=[_message_response(text="hi there")])
param = ChatCompletionsParam(
model="claude-test",
messages=[UserChatMessage(content="hello")],
)
result = await dispatch_messages(cast("Any", client), param, stream=False)
assert result.choices[0].message.content == "hi there"
assert result.choices[0].finish_reason == "stop"
assert len(client.calls) == 1
assert client.calls[0].max_tokens == 8192
async def test_dispatch_messages_stream_text_and_usage() -> None:
events: list[AnthropicStreamEvent] = [
AnthropicMessageStart(
message=AnthropicMessageResponse(
id="msg_s",
model="claude-test",
content=[],
usage=AnthropicUsage(input_tokens=8, output_tokens=0),
)
),
AnthropicContentBlockStart(
index=0,
content_block=AnthropicRawContentBlock(type="text", text=""),
),
AnthropicContentBlockDelta(
index=0,
delta=AnthropicRawContentBlock(type="text_delta", text="stream"),
),
AnthropicContentBlockDelta(
index=0,
delta=AnthropicRawContentBlock(type="text_delta", text="ed"),
),
AnthropicMessageDelta(
delta={"stop_reason": "end_turn"},
usage=AnthropicUsage(input_tokens=0, output_tokens=4),
),
AnthropicMessageStop(),
]
client = ScriptedMessagesClient(stream_events=[events])
param = ChatCompletionsParam(
model="claude-test",
messages=[UserChatMessage(content="hi")],
)
stream = await dispatch_messages(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
texts = [c.choices[0].delta.content for c in chunks if c.choices and isinstance(c.choices[0].delta.content, str)]
assert texts == ["stream", "ed"]
assert any(c.choices and c.choices[0].finish_reason == "stop" for c in chunks)
assert any(getattr(c, "usage", UNSET) not in (UNSET, None) for c in chunks)
async def test_run_chat_loop_messages_kind_text() -> None:
client = ScriptedMessagesClient(non_stream=[_message_response(text="done")])
messages: list[Any] = [UserChatMessage(content="go")]
events = [
event
async for event in run_chat_loop(
cast("Any", client),
messages,
model="claude-test",
stream=False,
)
]
assert any(isinstance(e, TextDeltaEvent) and e.content == "done" for e in events)
assert any(isinstance(e, AssistantMessageEvent) for e in events)
assert any(isinstance(e, UsageEvent) and e.usage.prompt_tokens == 10 for e in events)
assert isinstance(messages[-1], AssistantChatMessage)
assert messages[-1].content == "done"
async def test_run_chat_loop_messages_kind_tool_round() -> None:
first = _message_response(
text="",
tool_uses=[("call_1", "add", {"a": 1, "b": 2})],
)
second = _message_response(text="sum is 3")
client = ScriptedMessagesClient(non_stream=[first, second])
@tool(register=False)
def add(a: int, b: int) -> str:
return str(a + b)
registry = ToolRegistry([add])
messages: list[Any] = [UserChatMessage(content="add 1 and 2")]
events = [
event
async for event in run_chat_loop(
cast("Any", client),
messages,
model="claude-test",
tools=registry,
stream=False,
)
]
assert any(isinstance(e, ToolCallEvent) for e in events)
assert any(isinstance(e, ToolResultEvent) and "3" in e.message.content for e in events)
assert any(isinstance(e, AssistantMessageEvent) and e.message.content == "sum is 3" for e in events)
assert len(client.calls) == 2
# Second turn must include tool_result in a user message.
second_msgs = client.calls[1].messages
assert any(
isinstance(m, AnthropicUserMessage)
and isinstance(m.content, list)
and any(isinstance(b, AnthropicToolResultContent) and b.tool_use_id == "call_1" for b in m.content)
for m in second_msgs
)
async def test_stream_tool_call_deltas() -> None:
events: list[AnthropicStreamEvent] = [
AnthropicMessageStart(
message=AnthropicMessageResponse(
id="msg_s",
model="claude-test",
content=[],
usage=AnthropicUsage(input_tokens=5, output_tokens=0),
)
),
AnthropicContentBlockStart(
index=0,
content_block=AnthropicRawContentBlock(type="tool_use", id="tu_9", name="add"),
),
AnthropicContentBlockDelta(
index=0,
delta=AnthropicRawContentBlock(type="input_json_delta", partial_json='{"a":'),
),
AnthropicContentBlockDelta(
index=0,
delta=AnthropicRawContentBlock(type="input_json_delta", partial_json="1}"),
),
AnthropicMessageDelta(
delta={"stop_reason": "tool_use"},
usage=AnthropicUsage(input_tokens=0, output_tokens=6),
),
AnthropicMessageStop(),
]
client = ScriptedMessagesClient(stream_events=[events])
@tool(register=False)
def add(a: int) -> str:
return str(a)
registry = ToolRegistry([add])
# Second streamed round after tool execution.
client._stream_events.append(
[
AnthropicMessageStart(
message=AnthropicMessageResponse(
id="msg_2",
model="claude-test",
content=[],
usage=AnthropicUsage(input_tokens=12, output_tokens=0),
)
),
AnthropicContentBlockDelta(
index=0,
delta=AnthropicRawContentBlock(type="text_delta", text="ok"),
),
AnthropicMessageDelta(
delta={"stop_reason": "end_turn"},
usage=AnthropicUsage(input_tokens=0, output_tokens=1),
),
AnthropicMessageStop(),
]
)
messages: list[Any] = [UserChatMessage(content="go")]
events_out = [
event
async for event in run_chat_loop(
cast("Any", client),
messages,
model="claude-test",
tools=registry,
stream=True,
)
]
assert any(isinstance(e, ToolCallEvent) for e in events_out)
assert any(isinstance(e, ToolResultEvent) for e in events_out)
assert any(isinstance(e, AssistantMessageEvent) and e.message.content == "ok" for e in events_out)
async def test_dispatch_stream_missing_terminal_yields_no_finish() -> None:
"""Stream with payload but no message_stop/stop_reason yields no finish chunk."""
events: list[AnthropicStreamEvent] = [
AnthropicMessageStart(
message=AnthropicMessageResponse(
id="msg_s",
model="claude-test",
content=[],
usage=AnthropicUsage(input_tokens=5, output_tokens=0),
)
),
AnthropicContentBlockDelta(
index=0,
delta=AnthropicRawContentBlock(type="text_delta", text="partial"),
),
# no message_delta stop_reason, no message_stop
]
client = ScriptedMessagesClient(stream_events=[events])
param = ChatCompletionsParam(model="claude-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_messages(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
# Only the text delta; no finish_reason / usage terminal chunk.
assert len(chunks) == 1
assert chunks[0].choices and chunks[0].choices[0].delta.content == "partial"
assert not any(c.choices and c.choices[0].finish_reason not in (None, UNSET) for c in chunks)
async def test_dispatch_stream_tool_calls_without_stop_yields_no_finish() -> None:
"""Tool-call deltas without a stop signal must NOT fabricate finish_reason."""
events: list[AnthropicStreamEvent] = [
AnthropicMessageStart(
message=AnthropicMessageResponse(
id="msg_s",
model="claude-test",
content=[],
usage=AnthropicUsage(input_tokens=5, output_tokens=0),
)
),
AnthropicContentBlockStart(
index=0,
content_block=AnthropicRawContentBlock(type="tool_use", id="tu_1", name="add"),
),
# stream dies here: no message_delta stop_reason, no message_stop
]
client = ScriptedMessagesClient(stream_events=[events])
param = ChatCompletionsParam(model="claude-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_messages(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
# tool-call start delta only; no fabricated finish_reason=tool_calls
assert not any(c.choices and c.choices[0].finish_reason not in (None, UNSET) for c in chunks)
+46 -45
View File
@@ -1,7 +1,4 @@
from __future__ import annotations
import msgspec
import pytest
from plyngent.agent.responses_bridge import (
chat_messages_to_responses_input,
@@ -11,8 +8,7 @@ from plyngent.agent.responses_bridge import (
responses_status_to_finish_reason,
tool_items_to_response_tools,
)
from plyngent.agent.responses_client import ResponsesChatClient
from plyngent.lmproto.openai.model import Response, ResponsesCreateParam
from plyngent.lmproto.openai.model import Response, ResponseFunctionToolCallOutput
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionTool,
@@ -61,6 +57,51 @@ def test_chat_messages_to_input_and_instructions() -> None:
assert len(items) == 3 # user, function_call, function_call_output
def test_parallel_tool_outputs_interleaved_after_calls() -> None:
"""Regression: DeepSeek Responses rejects call, call, output, output batches.
Each ``function_call_output`` must directly follow its ``function_call`` so
strict/adjacency-matching servers (DeepSeek) accept parallel tool rounds.
"""
messages = [
UserChatMessage(content="run both"),
AssistantChatMessage(
content=None,
tool_calls=[
AssistantFunctionToolCall(
id="call_01_LPBuUG3KTaMDIwUoLwTE9127",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
),
AssistantFunctionToolCall(
id="call_02_ABCD",
function=AssistantFunctionTool(name="grep_files", arguments='{"pattern":"todo"}'),
),
],
),
ToolChatMessage(content="file body", tool_call_id="call_01_LPBuUG3KTaMDIwUoLwTE9127"),
ToolChatMessage(content="grep hit", tool_call_id="call_02_ABCD"),
]
_, items = chat_messages_to_responses_input(messages)
types = [item.get("type") if isinstance(item, dict) else item.__struct_config__.tag for item in items]
assert types == [
"message",
"function_call",
"function_call_output",
"function_call",
"function_call_output",
]
# Each output is paired with its own call, in stream order.
call_a = items[1]
out_a = items[2]
call_b = items[3]
out_b = items[4]
assert isinstance(call_a, dict) and isinstance(call_b, dict)
assert isinstance(out_a, ResponseFunctionToolCallOutput)
assert isinstance(out_b, ResponseFunctionToolCallOutput)
assert out_a.call_id == call_a["call_id"]
assert out_b.call_id == call_b["call_id"]
def test_response_to_assistant_with_tools() -> None:
raw = {
"id": "resp_1",
@@ -158,43 +199,3 @@ def test_provider_tools_merged_after_local_functions() -> None:
assert tools[0].name == "local_tool"
assert tools[1]["type"] == "web_search"
assert tools[2]["vector_store_ids"] == ["vs_1"]
@pytest.mark.asyncio
async def test_responses_chat_client_non_stream(monkeypatch: pytest.MonkeyPatch) -> None:
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
platform = OpenAIClient(OpenAIConfig(access_key_or_token="sk", base_url="https://example/v1"))
body = {
"id": "resp_x",
"object": "response",
"created_at": 1,
"model": "gpt-test",
"status": "completed",
"output": [
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "hello", "annotations": []}],
}
],
"usage": {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4},
}
async def fake_responses(param: ResponsesCreateParam, *, stream: bool = False):
assert stream is False
assert param.model == "gpt-test"
assert param.store is False
return msgspec.convert(body, Response)
monkeypatch.setattr(platform, "responses", fake_responses)
client = ResponsesChatClient(platform)
result = await client.chat_completions(
ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="hi")]),
stream=False,
)
assert result.choices[0].message.content == "hello"
+384
View File
@@ -0,0 +1,384 @@
"""Tests for kind-based OpenAI Responses dispatch in the agent loop."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import pytest
from msgspec import UNSET
from plyngent.agent import (
AssistantMessageEvent,
TextDeltaEvent,
ToolCallEvent,
ToolRegistry,
ToolResultEvent,
UsageEvent,
run_chat_loop,
tool,
)
from plyngent.agent.responses_bridge import chat_param_to_responses_kwargs
from plyngent.agent.responses_dispatch import dispatch_responses
from plyngent.lmproto.openai.model import Response, ResponsesCreateParam, ResponseStreamEvent
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
ChatCompletionsParam,
SystemChatMessage,
ToolFunction,
ToolFunctionItem,
UserChatMessage,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
def _completed_response(
*,
text: str = "hello",
tool_calls: list[dict[str, Any]] | None = None,
usage: dict[str, Any] | None = None,
status: str = "completed",
) -> Response:
output: list[dict[str, Any]] = []
if text:
output.append(
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": text, "annotations": []}],
}
)
if tool_calls:
output.extend(tool_calls)
return Response(
id="resp_test",
created_at=1,
model="gpt-test",
status=cast("Any", status),
output=output,
usage=usage if usage is not None else {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
class ScriptedResponsesClient:
"""Fake OpenAIClient that scripts ``responses()`` results."""
kind: str = "responses"
calls: list[ResponsesCreateParam]
_non_stream: list[Response]
_stream_events: list[list[ResponseStreamEvent]]
def __init__(
self,
*,
non_stream: Sequence[Response] | None = None,
stream_events: Sequence[Sequence[ResponseStreamEvent]] | None = None,
) -> None:
self.calls = []
self._non_stream = list(non_stream or ())
self._stream_events = [list(events) for events in (stream_events or ())]
@overload
async def responses(self, param: ResponsesCreateParam, *, stream: Literal[False] = False) -> Response: ...
@overload
async def responses(
self, param: ResponsesCreateParam, *, stream: Literal[True]
) -> AsyncIterator[ResponseStreamEvent]: ...
async def responses(
self, param: ResponsesCreateParam, *, stream: bool = False
) -> Response | AsyncIterator[ResponseStreamEvent]:
self.calls.append(param)
if stream:
if not self._stream_events:
msg = "no more scripted stream events"
raise RuntimeError(msg)
events = self._stream_events.pop(0)
async def _gen() -> AsyncIterator[ResponseStreamEvent]:
for event in events:
yield event
return _gen()
if not self._non_stream:
msg = "no more scripted responses"
raise RuntimeError(msg)
return self._non_stream.pop(0)
async def test_dispatch_responses_non_stream_text() -> None:
client = ScriptedResponsesClient(non_stream=[_completed_response(text="hi there")])
param = ChatCompletionsParam(
model="gpt-test",
messages=[UserChatMessage(content="hello")],
)
result = await dispatch_responses(cast("Any", client), param, stream=False)
assert result.choices[0].message.content == "hi there"
assert result.choices[0].finish_reason == "stop"
assert len(client.calls) == 1
assert client.calls[0].store is False
async def test_dispatch_responses_merges_provider_tools() -> None:
client = ScriptedResponsesClient(non_stream=[_completed_response()])
param = ChatCompletionsParam(
model="gpt-test",
messages=[UserChatMessage(content="search")],
tools=[
ToolFunctionItem(
function=ToolFunction(
name="read_file",
description="Read a file",
parameters={"type": "object", "properties": {}},
)
)
],
)
_ = await dispatch_responses(
cast("Any", client),
param,
provider_tools=[{"type": "web_search"}],
stream=False,
)
create = client.calls[0]
assert create.tools is not UNSET
tools = cast("list[Any]", create.tools)
assert len(tools) == 2
# Local function tool first, hosted tool second.
names_or_types: list[str] = []
for item in tools:
if isinstance(item, dict):
names_or_types.append(str(item.get("type", "")))
else:
names_or_types.append(getattr(item, "name", ""))
assert "read_file" in names_or_types
assert "web_search" in names_or_types
async def test_dispatch_responses_stream_text_and_usage() -> None:
final = _completed_response(text="streamed")
# Final response body is carried on the completed event as a dict.
import msgspec
final_dict = msgspec.to_builtins(final)
events = [
ResponseStreamEvent(type="response.output_text.delta", delta="stream"),
ResponseStreamEvent(type="response.output_text.delta", delta="ed"),
ResponseStreamEvent(type="response.completed", response=final_dict),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(
model="gpt-test",
messages=[UserChatMessage(content="hi")],
)
stream = await dispatch_responses(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
# text deltas + finish + usage
texts = [c.choices[0].delta.content for c in chunks if c.choices and isinstance(c.choices[0].delta.content, str)]
assert texts == ["stream", "ed"]
assert any(c.choices and c.choices[0].finish_reason == "stop" for c in chunks)
assert any(token_usage_present(c) for c in chunks)
def token_usage_present(chunk: object) -> bool:
usage = getattr(chunk, "usage", UNSET)
return usage is not UNSET and usage is not None
async def test_run_chat_loop_responses_kind_text() -> None:
client = ScriptedResponsesClient(non_stream=[_completed_response(text="done")])
messages: list[Any] = [UserChatMessage(content="go")]
events = [
event
async for event in run_chat_loop(
cast("Any", client),
messages,
model="gpt-test",
stream=False,
)
]
assert any(isinstance(e, TextDeltaEvent) and e.content == "done" for e in events)
assert any(isinstance(e, AssistantMessageEvent) for e in events)
assert any(isinstance(e, UsageEvent) for e in events)
assert isinstance(messages[-1], AssistantChatMessage)
assert messages[-1].content == "done"
async def test_run_chat_loop_responses_kind_tool_round() -> None:
tool_call_item = {
"type": "function_call",
"call_id": "call_1",
"name": "add",
"arguments": '{"a": 1, "b": 2}',
"status": "completed",
}
first = _completed_response(text="", tool_calls=[tool_call_item])
second = _completed_response(text="sum is 3")
client = ScriptedResponsesClient(non_stream=[first, second])
@tool(register=False)
def add(a: int, b: int) -> str:
return str(a + b)
registry = ToolRegistry([add])
messages: list[Any] = [UserChatMessage(content="add 1 and 2")]
events = [
event
async for event in run_chat_loop(
cast("Any", client),
messages,
model="gpt-test",
tools=registry,
stream=False,
)
]
assert any(isinstance(e, ToolCallEvent) for e in events)
assert any(isinstance(e, ToolResultEvent) and "3" in e.message.content for e in events)
assert any(isinstance(e, AssistantMessageEvent) and e.message.content == "sum is 3" for e in events)
assert len(client.calls) == 2
# Second turn must include the function_call_output from the tool result.
from plyngent.lmproto.openai.model import ResponseFunctionToolCallOutput
second_input = client.calls[1].input
assert isinstance(second_input, list)
assert any(
isinstance(item, ResponseFunctionToolCallOutput)
or (isinstance(item, dict) and item.get("type") == "function_call_output")
for item in second_input
)
async def test_run_chat_loop_responses_provider_tools_passed() -> None:
client = ScriptedResponsesClient(non_stream=[_completed_response(text="ok")])
messages: list[Any] = [UserChatMessage(content="hi")]
_ = [
event
async for event in run_chat_loop(
cast("Any", client),
messages,
model="gpt-test",
stream=False,
provider_tools=[{"type": "web_search"}],
)
]
create = client.calls[0]
assert create.tools is not UNSET
tools = cast("list[Any]", create.tools)
assert any(isinstance(t, dict) and t.get("type") == "web_search" for t in tools)
async def test_dispatch_stream_missing_completed_yields_no_terminal() -> None:
"""Stream without response.completed produces only early deltas (if any)."""
events = [
ResponseStreamEvent(type="response.output_text.delta", delta="partial"),
# no completed event
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
assert len(chunks) == 1 # only the text delta
assert chunks[0].choices[0].delta.content == "partial"
assert chunks[0].choices[0].finish_reason is None or chunks[0].choices[0].finish_reason is UNSET
def test_chat_param_to_responses_kwargs_system_and_tools() -> None:
param = ChatCompletionsParam(
model="gpt-test",
messages=[
SystemChatMessage(content="Be brief."),
UserChatMessage(content="hi"),
],
tools=[ToolFunctionItem(function=ToolFunction(name="f", description="d", parameters={"type": "object"}))],
)
kwargs = chat_param_to_responses_kwargs(param, provider_tools=[{"type": "web_search"}])
assert kwargs["instructions"] == "Be brief."
assert kwargs["store"] is False
assert len(kwargs["tools"]) == 2
async def test_unknown_kind_not_implemented() -> None:
class FakeOther:
kind = "weird"
with pytest.raises(NotImplementedError, match="weird"):
async for _ in run_chat_loop(
cast("Any", FakeOther()),
[UserChatMessage(content="hi")],
model="x",
stream=False,
):
pass
async def test_dispatch_stream_raises_on_error_event() -> None:
import pytest
events = [
ResponseStreamEvent(type="error", code="server_error", message="boom"),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
with pytest.raises(RuntimeError, match="boom"):
async for _ in cast("Any", stream):
pass
async def test_dispatch_stream_incomplete_maps_to_length() -> None:
"""response.incomplete carries the full response; finish becomes ``length``."""
import msgspec
final = _completed_response(text="cut short", status="incomplete")
final_dict = msgspec.to_builtins(final)
events = [
ResponseStreamEvent(type="response.output_text.delta", delta="cut "),
ResponseStreamEvent(type="response.incomplete", response=final_dict),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
chunks = [chunk async for chunk in cast("Any", stream)]
assert any(c.choices and c.choices[0].finish_reason == "length" for c in chunks)
async def test_dispatch_stream_failed_raises_with_error_detail() -> None:
"""response.failed surfaces the embedded error instead of a missing terminal."""
events = [
ResponseStreamEvent(
type="response.failed",
response={
"id": "resp_1",
"object": "response",
"created_at": 1,
"model": "deepseek-v4-flash",
"status": "failed",
"output": [],
"error": {"code": "rate_limit_exceeded", "message": "slow down"},
},
),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
with pytest.raises(RuntimeError, match="slow down"):
async for _ in cast("Any", stream):
pass
async def test_dispatch_stream_failed_falls_back_to_event_message() -> None:
"""response.failed without an embedded error uses the event message."""
events = [
ResponseStreamEvent(type="response.failed", message="backend error", code="server_error"),
]
client = ScriptedResponsesClient(stream_events=[events])
param = ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="x")])
stream = await dispatch_responses(cast("Any", client), param, stream=True)
with pytest.raises(RuntimeError, match="backend error"):
async for _ in cast("Any", stream):
pass
@@ -0,0 +1,215 @@
"""Integration tests: full run-fail-retry flow, synthetic_tool nag detection.
Uses mock clients to simulate model responses across retries, covering:
- No turn-start nag is ever injected (nag only fires after complete turns)
- End-of-turn nag fires after a successful retry when model doesn't touch stack
- No duplicate synthetic pairs accumulate in history
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
import pytest
from msgspec import UNSET
from plyngent.agent import ChatAgent
from plyngent.agent.todo_nag import is_synthetic_todo_nag_call_id
from plyngent.agent.todo_stack import TodoStack
from plyngent.config.models import DatabaseConfig
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
AssistantChatMessage,
AssistantFunctionToolCall,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
ChunkChoice,
DeltaMessage,
ToolChatMessage,
)
from plyngent.memory import MemoryStore
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
return ChatCompletionResponse(
id="r",
object="chat.completion",
created=0,
model="m",
choices=[
ChatCompletionChoice(
index=0,
message=message,
logprobs={},
finish_reason="stop",
)
],
system_fingerprint="",
usage={},
)
def _chunks_from_text(text: str) -> list[ChatCompletionChunk]:
chunks: list[ChatCompletionChunk] = []
if text:
chunks.append(
ChatCompletionChunk(
id="c",
object="chat.completion.chunk",
created=0,
model="m",
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(content=text),
finish_reason=None,
)
],
)
)
chunks.append(
ChatCompletionChunk(
id="c",
object="chat.completion.chunk",
created=0,
model="m",
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(),
finish_reason="stop",
)
],
)
)
return chunks
def count_synthetic_pairs(messages: list[AnyChatMessage]) -> int:
"""Count complete synthetic todo nag pairs in messages."""
count = 0
for i, m in enumerate(messages):
if not isinstance(m, AssistantChatMessage):
continue
tc = m.tool_calls
if tc is UNSET or not tc:
continue
if not all(isinstance(c, AssistantFunctionToolCall) and is_synthetic_todo_nag_call_id(c.id) for c in tc):
continue
if i + 1 < len(messages) and isinstance(messages[i + 1], ToolChatMessage):
count += 1
return count
class FailTwiceThenTextClient:
"""First 2 stream calls fail; 3rd returns text (no tools)."""
def __init__(self) -> None:
self.stream_calls = 0
self.non_stream_calls = 0
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
if stream:
self.stream_calls += 1
if self.stream_calls <= 2:
msg = f"stream fail #{self.stream_calls}"
raise RuntimeError(msg)
return self._stream_ok()
self.non_stream_calls += 1
return _response(AssistantChatMessage(content="ok"))
async def _stream_ok(self) -> AsyncIterator[ChatCompletionChunk]:
for chunk in _chunks_from_text("final answer"):
yield chunk
async def __aenter__(self):
return self
async def __aexit__(self, *exc: object) -> None:
return None
@pytest.mark.asyncio
async def test_no_turn_start_nag_on_initial_run() -> None:
"""No turn-start nag is injected on initial run — only end-of-turn."""
store = await MemoryStore.open(DatabaseConfig())
session = await store.create_session(name="t")
stack = TodoStack()
_ = stack.push("task A")
client = FailTwiceThenTextClient()
agent = ChatAgent(
client,
model="m",
memory=store,
session_id=session.sid,
todo_stack=stack,
todo_nag_strategy="synthetic_tool",
)
# First run fails — no turn-start nag
with pytest.raises(RuntimeError, match="stream fail"):
async for _ in agent.run("hi"):
pass
assert count_synthetic_pairs(agent.messages) == 0, "0 pairs after first failure"
await store.close()
@pytest.mark.asyncio
async def test_end_of_turn_nag_on_successful_retry() -> None:
"""End-of-turn nag fires after a successful retry when stack untouched."""
store = await MemoryStore.open(DatabaseConfig())
session = await store.create_session(name="t")
stack = TodoStack()
_ = stack.push("task B")
client = FailTwiceThenTextClient()
agent = ChatAgent(
client,
model="m",
memory=store,
session_id=session.sid,
todo_stack=stack,
todo_nag_strategy="synthetic_tool",
)
# Run fails
with pytest.raises(RuntimeError, match="stream fail"):
async for _ in agent.run("hi"):
pass
# Retry fails
with pytest.raises(RuntimeError, match="stream fail"):
async for _ in agent.retry():
pass
assert count_synthetic_pairs(agent.messages) == 0, "0 pairs after failures"
# Retry succeeds — end-of-turn nag fires
async for _ in agent.retry():
pass
assert count_synthetic_pairs(agent.messages) == 1, "1 pair after success (end-of-turn nag only)"
await store.close()
+139
View File
@@ -0,0 +1,139 @@
"""Tests for committed-prefix rollback preserving synthetic todo nag pairs.
Covers committed_prefix_end: after a failed turn, rollback must keep committed
tool batches (including synthetic todo nag pairs) so retry does not re-execute
side effects or duplicate nags.
"""
from __future__ import annotations
from msgspec import UNSET
from plyngent.agent.chat import committed_prefix_end
from plyngent.agent.todo_nag import (
_append_synthetic_todo_list,
is_synthetic_todo_nag_call_id,
)
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
SystemChatMessage,
ToolChatMessage,
UserChatMessage,
)
def test_synthetic_pair_survives_rollback() -> None:
"""The synthetic pair survives committed_prefix_end + deletion."""
messages: list[AnyChatMessage] = [
SystemChatMessage(content="sys"),
UserChatMessage(content="hi"),
]
_append_synthetic_todo_list(messages, "open task: fix bug")
user_index = 1
end = committed_prefix_end(messages, user_index)
del messages[end:]
# Pair should still be in messages
assert len(messages) == 4
assert isinstance(messages[2], AssistantChatMessage)
assert isinstance(messages[3], ToolChatMessage)
def test_synthetic_pair_survives_rollback_with_subsequent_committed_content() -> None:
"""The pair survives when there's also committed content after it."""
messages: list[AnyChatMessage] = [
SystemChatMessage(content="sys"),
UserChatMessage(content="hi"),
]
_append_synthetic_todo_list(messages, "open task: fix bug")
# Add committed tool batch (real tool call + result)
real_call_id = "call_real_1"
messages.append(
AssistantChatMessage(
content=UNSET,
tool_calls=[
AssistantFunctionToolCall(
id=real_call_id,
function=AssistantFunctionTool(
name="some_tool",
arguments="{}",
),
)
],
)
)
messages.append(ToolChatMessage(tool_call_id=real_call_id, content="done"))
# Add uncommitted content that should be removed
messages.append(AssistantChatMessage(content="uncommitted response"))
user_index = 1
end = committed_prefix_end(messages, user_index)
# end should be 6 (user+1=2, synth_pair=2->4, real_tool=4->6, then stop at 6)
assert end == 6, f"Expected end=6, got {end}"
del messages[end:]
# Both the synthetic pair and the real tool batch survive.
assert len(messages) == 6
def test_multiple_rollbacks_preserve_single_pair() -> None:
"""Repeated rollbacks don't accumulate duplicate synthetic pairs."""
messages: list[AnyChatMessage] = [
SystemChatMessage(content="sys"),
UserChatMessage(content="hi"),
]
_append_synthetic_todo_list(messages, "task 1")
user_index = 1
# Simulate first failure: roll back uncommitted content
end = committed_prefix_end(messages, user_index)
del messages[end:]
# Simulate second failure: roll back again
end = committed_prefix_end(messages, user_index)
del messages[end:]
# Count pairs
count = sum(
1
for i, m in enumerate(messages)
if isinstance(m, AssistantChatMessage)
and m.tool_calls is not UNSET
and m.tool_calls
and all(isinstance(c, AssistantFunctionToolCall) and is_synthetic_todo_nag_call_id(c.id) for c in m.tool_calls)
and i + 1 < len(messages)
and isinstance(messages[i + 1], ToolChatMessage)
)
assert count == 1, f"Expected 1 synthetic pair, got {count}"
def test_rollback_keeps_real_tool_batch_not_matching() -> None:
"""Real tool calls are committed prefix, not treated as synthetic nag."""
messages: list[AnyChatMessage] = [
SystemChatMessage(content="sys"),
UserChatMessage(content="hi"),
AssistantChatMessage(
content=UNSET,
tool_calls=[
AssistantFunctionToolCall(
id="call_real",
function=AssistantFunctionTool(name="tool", arguments="{}"),
)
],
),
ToolChatMessage(tool_call_id="call_real", content="done"),
AssistantChatMessage(content="uncommitted response"),
]
user_index = 1
end = committed_prefix_end(messages, user_index)
# user + real tool batch = 4; trailing text assistant not committed.
assert end == 4, f"Expected end=4, got {end}"
del messages[end:]
assert len(messages) == 4
+5 -5
View File
@@ -342,7 +342,7 @@ async def test_loop_injects_todo_review_when_untouched() -> None:
stack.begin_turn()
client = ScriptedClient()
agent = ChatAgent(
client, # type: ignore[arg-type]
client,
model="m",
tools=_todo_registry(stack),
stream=False,
@@ -420,7 +420,7 @@ async def test_loop_skips_todo_review_when_touched_even_if_open() -> None:
client = ScriptedClientWithTodoListThenStop()
agent = ChatAgent(
client, # type: ignore[arg-type]
client,
model="m",
tools=_todo_registry(stack),
stream=False,
@@ -447,7 +447,7 @@ async def test_loop_synthetic_tool_nag_strategy() -> None:
stack.begin_turn()
client = ScriptedClient()
agent = ChatAgent(
client, # type: ignore[arg-type]
client,
model="m",
tools=_todo_registry(stack),
stream=False,
@@ -471,7 +471,7 @@ async def test_loop_none_nag_strategy_skips_inject() -> None:
stack.begin_turn()
client = ScriptedClient()
agent = ChatAgent(
client, # type: ignore[arg-type]
client,
model="m",
tools=_todo_registry(stack),
stream=False,
@@ -580,7 +580,7 @@ async def test_loop_synthetic_tool_refreshes_after_stack_cleared() -> None:
_ = stack.push("was dirty")
client = CaptureClient()
agent = ChatAgent(
client, # type: ignore[arg-type]
client,
model="m",
tools=_todo_registry(stack),
stream=False,
+17
View File
@@ -58,6 +58,23 @@ def test_token_usage_from_api_responses_fields() -> None:
assert u.total_tokens == 9
def test_token_usage_from_api_struct() -> None:
from plyngent.lmproto.anthropic.model import AnthropicUsage
u = token_usage_from_api(AnthropicUsage(input_tokens=7, output_tokens=2))
assert u is not None
assert u.prompt_tokens == 7
assert u.completion_tokens == 2
assert u.total_tokens == 9
assert u.source == "api"
def test_token_usage_from_api_empty_struct_returns_none() -> None:
from plyngent.lmproto.anthropic.model import AnthropicUsage
assert token_usage_from_api(AnthropicUsage()) is None
def test_chars_to_tokens() -> None:
assert chars_to_tokens(0) == 0
assert chars_to_tokens(1) == 1
+3 -3
View File
@@ -108,7 +108,7 @@ access_key_or_token = "sk"
async def empty() -> AsyncIterator[ChatCompletionChunk]:
if False:
yield # type: ignore[misc]
yield
return
return empty()
@@ -129,10 +129,10 @@ access_key_or_token = "sk"
usage={},
)
monkeypatch.setattr("plyngent.cli.app.create_client", lambda _p: DummyClient())
monkeypatch.setattr("plyngent.cli.app.create_client", lambda _p, **_kw: DummyClient())
monkeypatch.setattr(
"plyngent.cli.state.create_client",
lambda _p: DummyClient(),
lambda _p, **_kw: DummyClient(),
)
from platformdirs import user_data_path
+29 -1
View File
@@ -446,7 +446,8 @@ async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
)
try:
assert st.agent.tools is not None
# Soft-confirm hooks stay installed; YOLO only auto-allows YOLO-tagged tools.
# Soft-confirm hooks stay installed; YOLO mode does not bypass
# dangerous invocations — internal danger classification takes precedence.
assert st.agent.tools.soft_confirm is True
assert st.agent.tools.yolo is False
st.set_yolo("on")
@@ -565,3 +566,30 @@ async def test_status_shows_context_tokens(state: ReplState, capsys: pytest.Capt
out2 = capsys.readouterr().out
assert "context_tokens=1234/" in out2
assert "(api)" in out2
async def test_repl_ctrl_c_from_turn_returns_to_prompt(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A Ctrl+C escaping a turn lands back at the prompt, not out of the REPL."""
from plyngent.cli import repl as repl_mod
from plyngent.cli.repl import run_repl
calls: dict[str, int] = {"reads": 0}
def fake_read_entry() -> str:
calls["reads"] += 1
if calls["reads"] >= 2:
raise EOFError
return "hello"
async def interrupted_turn(_agent: object, _text: str) -> bool:
raise KeyboardInterrupt
monkeypatch.setattr(repl_mod, "setup_readline", lambda _state: None)
monkeypatch.setattr(repl_mod, "read_repl_entry", fake_read_entry)
monkeypatch.setattr(repl_mod, "run_user_text_with_retries", interrupted_turn)
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
assert calls["reads"] == 2
+97
View File
@@ -8,6 +8,7 @@ from plyngent.agent import ChatAgent
from plyngent.cli.retry import (
DEFAULT_MAX_AUTO_RETRIES,
DEFAULT_RETRY_DELAYS_SECONDS,
_wait_for_retry,
default_retry_delays,
retry_pending_with_retries,
run_turn_with_retries,
@@ -199,6 +200,102 @@ async def test_cancel_turn_rolls_back_and_sets_pending() -> None:
await store.close()
async def test_second_ctrl_c_during_cancel_message_stays_in_repl(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: a second Ctrl+C during the cancel message must not exit.
After the turn task is cancelled the asyncio SIGINT handler is gone, so the
second Ctrl+C arrives as a KeyboardInterrupt while the "cancelled" message
is printing. It must be swallowed and control must return to the REPL
(``False``), not propagate out of ``run_turn_with_retries``.
"""
import asyncio
calls: list[int] = [0]
def raiser(message: object = None, **kwargs: object) -> None:
calls[0] += 1
if calls[0] == 1:
raise KeyboardInterrupt
monkeypatch.setattr("plyngent.cli.retry.click.secho", raiser)
store = await MemoryStore.open(DatabaseConfig())
session = await store.create_session(name="t")
class HangClient:
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
del param, stream
await asyncio.sleep(60)
return _response(AssistantChatMessage(content="never"))
agent = ChatAgent(HangClient(), model="m", memory=store, session_id=session.sid)
turn = asyncio.create_task(run_turn_with_retries(agent, starter=lambda: agent.run("cancel-me"), delays=()))
await asyncio.sleep(0.05)
_ = turn.cancel() # first Ctrl+C: cancels the in-flight turn
ok = await turn # must return False instead of raising KeyboardInterrupt
assert ok is False
assert agent.pending_retry_text == "cancel-me"
await store.close()
async def test_second_ctrl_c_during_retry_cancel_message_stays_benign(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: a second Ctrl+C during the retry-cancel message is benign."""
async def interrupted_sleep(_seconds: float) -> bool:
return False # first Ctrl+C interrupted the retry wait
monkeypatch.setattr("plyngent.cli.retry.sleep_cancellable", interrupted_sleep)
calls: list[int] = [0]
def raiser(message: object = None, **kwargs: object) -> None:
calls[0] += 1
if calls[0] == 2: # second Ctrl+C lands on the "auto-retry cancelled" line
raise KeyboardInterrupt
monkeypatch.setattr("plyngent.cli.retry.click.secho", raiser)
assert await _wait_for_retry(1, 3, 0.01) is False
async def test_second_ctrl_c_during_retry_announce_stays_benign(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A Ctrl+C during the "retrying" announcement does not abort the turn."""
async def ok_sleep(_seconds: float) -> bool:
return True
monkeypatch.setattr("plyngent.cli.retry.sleep_cancellable", ok_sleep)
calls: list[int] = [0]
def raiser(message: object = None, **kwargs: object) -> None:
calls[0] += 1
if calls[0] == 2: # Ctrl+C lands on the "retrying" line
raise KeyboardInterrupt
monkeypatch.setattr("plyngent.cli.retry.click.secho", raiser)
assert await _wait_for_retry(1, 3, 0.01) is True
async def test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
async def no_wait(_seconds: float) -> bool:
return True
+1 -1
View File
@@ -12,4 +12,4 @@ access_key_or_token = "sk-1145141919810"
[providers.foo1]
preset = "deepseek"
access_key_or_token = "sk-1145141919810"
convention = "anthropic"
unknown_field = "should-be-rejected"
+18
View File
@@ -28,6 +28,24 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
assert store.agent_config.max_context_tokens == 200_000
assert store.plugins_config.enable == []
assert store.plugins_config.disable == []
assert store.networking_config.ssrf_assume_public_cidrs == []
def test_networking_ssrf_assume_public_cidrs(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_ = path.write_text(
"""
[networking]
ssrf_assume_public_cidrs = ["198.18.0.0/15", "fdfe:dcba:9876::/48"]
""",
encoding="utf-8",
)
store = load(path)
assert store.networking_config.ssrf_assume_public_cidrs == [
"198.18.0.0/15",
"fdfe:dcba:9876::/48",
]
assert "ssrf_assume_public_cidrs" in store.networking
def test_compose_defaults_join_persona_and_directives() -> None:
+173
View File
@@ -137,6 +137,155 @@ def test_deepseek_explicit_models_override_defaults() -> None:
assert set(provider.models) == {"custom-only"}
def test_deepseek_convention_from_toml(tmp_path: Path) -> None:
path = tmp_path / "deepseek-convention.toml"
_ = path.write_text(
"""
[providers.ds]
preset = "deepseek"
access_key_or_token = "sk-test"
convention = "responses"
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
provider = config.providers["ds"]
assert isinstance(provider, DeepseekProvider)
assert provider.convention == "responses"
def test_deepseek_convention_invalid_is_bad_provider(tmp_path: Path) -> None:
path = tmp_path / "deepseek-bad-convention.toml"
_ = path.write_text(
"""
[providers.ds]
preset = "deepseek"
access_key_or_token = "sk-test"
convention = "bogus"
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
assert "ds" not in config.providers
assert "ds" in config.bad_providers
def test_deepseek_model_convention_override_parses(tmp_path: Path) -> None:
path = tmp_path / "deepseek-model-convention.toml"
_ = path.write_text(
"""
[providers.ds]
preset = "deepseek"
access_key_or_token = "sk-test"
[providers.ds.models]
"deepseek-v4-flash" = { text = true, convention = "responses" }
"deepseek-v4-pro" = { text = true }
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
provider = config.providers["ds"]
assert isinstance(provider, DeepseekProvider)
assert provider.models["deepseek-v4-flash"].convention == "responses"
assert provider.models["deepseek-v4-pro"].convention == ""
def test_deepseek_convention_write_back(tmp_path: Path) -> None:
path = tmp_path / "deepseek-convention-write.toml"
config = ConfigStore(path=path, document=tomlkit.document())
config.providers = {
"ds": DeepseekProvider(access_key_or_token="sk-test", convention="responses"),
}
config.write()
text = path.read_text(encoding="utf-8")
assert 'convention = "responses"' in text
again = plyngent.config.load(path)
assert again.providers["ds"].convention == "responses"
def test_deepseek_convention_default_not_written(tmp_path: Path) -> None:
from plyngent.config import ModelConfig
path = tmp_path / "deepseek-convention-default.toml"
config = ConfigStore(path=path, document=tomlkit.document())
config.providers = {
"ds": DeepseekProvider(
access_key_or_token="sk-test",
models={"m": ModelConfig(text=True)},
),
}
config.write()
text = path.read_text(encoding="utf-8")
assert "convention" not in text
def test_deepseek_effective_convention_precedence() -> None:
from plyngent.config import ModelConfig, resolve_effective_provider
provider = DeepseekProvider(
access_key_or_token="sk-test",
models={
"flash": ModelConfig(convention="responses"),
"pro": ModelConfig(),
},
)
# Provider default "" → chat completions convention.
effective = resolve_effective_provider(provider)
assert effective.convention == "openai"
# Model override wins.
effective = resolve_effective_provider(provider, model="flash")
assert effective.convention == "responses"
effective = resolve_effective_provider(provider, model="pro")
assert effective.convention == "openai"
# Provider-level convention is the fallback.
provider = DeepseekProvider(access_key_or_token="sk-test", convention="responses")
assert resolve_effective_provider(provider).convention == "responses"
# Non-deepseek presets leave convention empty.
from plyngent.config import OpenAIProvider
assert resolve_effective_provider(OpenAIProvider(access_key_or_token="sk")).convention == ""
def test_deepseek_anthropic_convention_default_url() -> None:
from plyngent.config import resolve_effective_provider
from plyngent.config.routing import DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, DEFAULT_DEEPSEEK_BASE_URL
provider = DeepseekProvider(access_key_or_token="sk-test", convention="anthropic")
effective = resolve_effective_provider(provider)
assert effective.convention == "anthropic"
assert effective.url == DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
# Custom provider url wins over the convention default.
provider = DeepseekProvider(
access_key_or_token="sk-test",
url="https://gateway.example/anthropic",
convention="anthropic",
)
assert resolve_effective_provider(provider).url == "https://gateway.example/anthropic"
# Other conventions keep the plain deepseek base.
for convention in ("", "openai", "responses"):
provider = DeepseekProvider(access_key_or_token="sk-test", convention=convention)
assert resolve_effective_provider(provider).url == DEFAULT_DEEPSEEK_BASE_URL
def test_deepseek_anthropic_model_convention_default_url() -> None:
from plyngent.config import ModelConfig, resolve_effective_provider
from plyngent.config.routing import DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
provider = DeepseekProvider(
access_key_or_token="sk-test",
models={"claude-proxy": ModelConfig(convention="anthropic")},
)
effective = resolve_effective_provider(provider, model="claude-proxy")
assert effective.convention == "anthropic"
assert effective.url == DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
# Provider default url for a model without convention stays the deepseek base.
other = resolve_effective_provider(provider, model=None)
assert other.url == "https://api.deepseek.com"
def test_read_empty_config() -> None:
config = plyngent.config.load(Path(__file__).parent / "plyngent-empty.toml")
assert isinstance(config.providers, Mapping)
@@ -189,6 +338,30 @@ models = {}
_ = config.promote_provider("hollow", [])
def test_model_level_preset_url_overrides_parse(tmp_path: Path) -> None:
path = tmp_path / "mixed.toml"
_ = path.write_text(
"""
[providers.mixed]
preset = "openai-compatible"
url = "https://gateway.example/v1"
access_key_or_token = "sk-test"
[providers.mixed.models]
"claude" = { text = true, preset = "anthropic", url = "https://api.anthropic.com/v1" }
"qwen" = { text = true }
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
provider = config.providers["mixed"]
assert isinstance(provider, OpenAICompatibleProvider)
assert provider.models["claude"].preset == "anthropic"
assert provider.models["claude"].url == "https://api.anthropic.com/v1"
assert provider.models["qwen"].preset == ""
assert provider.models["qwen"].url == ""
def test_read_invalid_config() -> None:
with pytest.raises(ConfigFormatError):
_ = plyngent.config.load(Path(__file__).parent / "plyngent-invalid.toml")
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import msgspec
import pytest
from plyngent.lmproto.anthropic import AnthropicClient
from plyngent.lmproto.anthropic.config import AnthropicConfig
from plyngent.lmproto.anthropic.model import (
AnthropicContentBlockDelta,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicMessageStop,
AnthropicResponseText,
AnthropicUsage,
AnthropicUserMessage,
)
def _sample_message_body() -> bytes:
return msgspec.json.encode(
AnthropicMessageResponse(
id="msg_1",
model="claude-test",
content=[AnthropicResponseText(text="hello world")],
stop_reason="end_turn",
usage=AnthropicUsage(input_tokens=9, output_tokens=3),
)
)
def test_messages_param_encode_omits_defaults() -> None:
param = AnthropicMessagesParam(
model="claude-test",
max_tokens=2048,
messages=[AnthropicUserMessage(content="hi")],
)
raw = msgspec.json.encode(param)
data = msgspec.json.decode(raw)
assert data["model"] == "claude-test"
# omit_defaults: default stream=False is not encoded until client sets stream
assert "stream" not in data
# Non-default fields ARE encoded
assert data["max_tokens"] == 2048
@pytest.mark.asyncio
async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
class _Resp:
status_code = 200
@property
def content(self) -> bytes:
return _sample_message_body()
async def fake_post(path: str, **kwargs: object) -> _Resp:
assert path == "/messages"
assert kwargs.get("stream") is False
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
result = await client.messages(AnthropicMessagesParam(model="claude-test", messages=[]))
assert isinstance(result, AnthropicMessageResponse)
assert result.content[0].text == "hello world"
assert result.usage.output_tokens == 3
@pytest.mark.asyncio
async def test_client_messages_stream(monkeypatch: pytest.MonkeyPatch) -> None:
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
class _Resp:
status_code = 200
async def iter_lines(self):
yield b'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hel"}}'
yield b'data: {"type":"message_stop"}'
def close(self) -> None:
return None
async def fake_post(path: str, **kwargs: object) -> _Resp:
assert path == "/messages"
assert kwargs.get("stream") is True
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
stream = await client.messages(AnthropicMessagesParam(model="claude-test", messages=[]), stream=True)
events = [event async for event in stream]
assert len(events) == 2
assert isinstance(events[0], AnthropicContentBlockDelta)
assert events[0].delta.text == "hel"
assert isinstance(events[1], AnthropicMessageStop)
+90
View File
@@ -0,0 +1,90 @@
"""Compatibility normalization for chat-completions wire requests."""
from __future__ import annotations
from collections.abc import Sequence
import msgspec
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_developer_messages_to_system,
developer_to_system_message,
)
from plyngent.lmproto.openai_compatible.model import (
AnyChatMessage,
AssistantChatMessage,
ChatCompletionsParam,
DeveloperChatMessage,
SystemChatMessage,
UserChatMessage,
)
def _roles(messages: Sequence[object]) -> list[str]:
raw = msgspec.json.decode(msgspec.json.encode(messages))
return [str(item["role"]) for item in raw]
def test_developer_to_system_message_preserves_content_and_name() -> None:
msg = DeveloperChatMessage(content="follow these rules", name="policy")
converted = developer_to_system_message(msg)
assert isinstance(converted, SystemChatMessage)
assert converted.content == "follow these rules"
assert converted.name == "policy"
def test_coerce_developer_messages_to_system() -> None:
messages: list[AnyChatMessage] = [
SystemChatMessage(content="system"),
DeveloperChatMessage(content="dev"),
UserChatMessage(content="hi"),
AssistantChatMessage(content="hello"),
]
coerced = coerce_developer_messages_to_system(messages)
assert coerced is not messages
assert _roles(coerced) == ["system", "system", "user", "assistant"]
assert coerced[1].content == "dev"
def test_coerce_returns_equal_list_when_no_developer() -> None:
messages: list[AnyChatMessage] = [SystemChatMessage(content="system"), UserChatMessage(content="hi")]
assert coerce_developer_messages_to_system(messages) == messages
def test_coerce_chat_param_encodes_without_developer_role() -> None:
param = ChatCompletionsParam(
model="model",
messages=[
DeveloperChatMessage(content="dev"),
UserChatMessage(content="hi"),
],
)
coerced = coerce_chat_completions_param(param)
raw = msgspec.json.decode(msgspec.json.encode(coerced))
assert raw["messages"][0]["role"] == "system"
assert raw["messages"][1]["role"] == "user"
def test_deepseek_param_accepts_coerced_messages() -> None:
base = ChatCompletionsParam(
model="deepseek",
messages=[DeveloperChatMessage(content="dev"), UserChatMessage(content="hi")],
)
coerced = coerce_chat_completions_param(base)
raw = msgspec.json.decode(msgspec.json.encode(coerced))
deepseek_param = msgspec.convert(raw, DeepseekChatCompletionsParam)
assert _roles(deepseek_param.messages) == ["system", "user"]
def test_generic_coercion_handles_deepseek_runtime_param() -> None:
base = ChatCompletionsParam(
model="deepseek",
messages=[DeveloperChatMessage(content="dev"), UserChatMessage(content="hi")],
)
# Runtime calls may pass a generic param through a DeepSeek-typed method.
coerced = coerce_chat_completions_param_any(base)
raw = msgspec.json.decode(msgspec.json.encode(coerced))
assert raw["messages"][0]["role"] == "system"
@@ -0,0 +1,95 @@
from __future__ import annotations
import msgspec
import pytest
from plyngent.lmproto.anthropic.config import AnthropicConfig
from plyngent.lmproto.anthropic.model import (
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicResponseText,
AnthropicUsage,
)
from plyngent.lmproto.deepseek import DeepseekAnthropicClient
def _sample_message_body() -> bytes:
return msgspec.json.encode(
AnthropicMessageResponse(
id="msg_1",
model="deepseek-v4-flash",
content=[AnthropicResponseText(text="hello world")],
stop_reason="end_turn",
usage=AnthropicUsage(input_tokens=9, output_tokens=3),
)
)
def test_client_kind_and_base_url() -> None:
client = DeepseekAnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
assert client.kind == "messages"
assert client.session.base_url == "https://api.deepseek.com/anthropic"
@pytest.mark.asyncio
async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
client = DeepseekAnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
class _Resp:
status_code = 200
@property
def content(self) -> bytes:
return _sample_message_body()
async def fake_post(path: str, **kwargs: object) -> _Resp:
assert path == "/messages"
assert kwargs.get("stream") is False
headers = kwargs.get("headers")
assert isinstance(headers, dict)
assert headers.get("x-api-key") == "sk-test"
assert "anthropic-version" in headers
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
result = await client.messages(AnthropicMessagesParam(model="deepseek-v4-flash", messages=[]))
assert isinstance(result, AnthropicMessageResponse)
assert result.content[0].text == "hello world"
assert result.usage.output_tokens == 3
@pytest.mark.asyncio
async def test_client_messages_stream(monkeypatch: pytest.MonkeyPatch) -> None:
client = DeepseekAnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
class _Resp:
status_code = 200
async def iter_lines(self):
yield b'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hel"}}'
yield b'data: {"type":"message_stop"}'
def close(self) -> None:
return None
async def fake_post(path: str, **kwargs: object) -> _Resp:
assert path == "/messages"
assert kwargs.get("stream") is True
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
stream = await client.messages(AnthropicMessagesParam(model="deepseek-v4-flash", messages=[]), stream=True)
events = [event async for event in stream]
assert len(events) == 2
@pytest.mark.asyncio
async def test_client_models_empty_without_network(monkeypatch: pytest.MonkeyPatch) -> None:
client = DeepseekAnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
async def fail_get(*_args: object, **_kwargs: object) -> object:
msg = "GET /models must not be called on the /anthropic base"
raise AssertionError(msg)
monkeypatch.setattr(client.session, "get", fail_get)
assert await client.models() == []
@@ -0,0 +1,89 @@
from __future__ import annotations
import pytest
from plyngent.lmproto.deepseek import DeepseekResponsesClient
from plyngent.lmproto.openai import Response, ResponsesCreateParam, response_output_text
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
def _sample_response_body() -> bytes:
return (
b'{"id":"resp_1","object":"response","created_at":1,"model":"deepseek-v4-flash",'
b'"status":"completed","output":['
b'{"id":"msg_1","type":"message","role":"assistant","status":"completed",'
b'"content":[{"type":"output_text","text":"hello from deepseek","annotations":[]}]}'
b"]}"
)
def test_client_kind_and_base_url() -> None:
client = DeepseekResponsesClient(OpenAIConfig(access_key_or_token="sk", base_url="https://api.deepseek.com"))
assert client.kind == "responses"
assert client.session.base_url == "https://api.deepseek.com"
@pytest.mark.asyncio
async def test_client_responses_create(monkeypatch: pytest.MonkeyPatch) -> None:
client = DeepseekResponsesClient(OpenAIConfig(access_key_or_token="sk", base_url="https://api.deepseek.com"))
class _Resp:
status_code = 200
@property
def content(self) -> bytes:
return _sample_response_body()
async def fake_post(path: str, **kwargs: object) -> _Resp:
assert path == "/responses"
assert kwargs.get("stream") is False
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
result = await client.responses(ResponsesCreateParam(model="deepseek-v4-flash", input="hi"))
assert isinstance(result, Response)
assert response_output_text(result) == "hello from deepseek"
@pytest.mark.asyncio
async def test_client_responses_stream_stops_at_terminal(monkeypatch: pytest.MonkeyPatch) -> None:
"""DeepSeek sends no ``[DONE]``: the stream must stop at a terminal event."""
client = DeepseekResponsesClient(OpenAIConfig(access_key_or_token="sk", base_url="https://api.deepseek.com"))
class _Resp:
status_code = 200
async def iter_lines(self):
yield (
b'data: {"type":"response.output_text.delta","delta":"hel",'
b'"item_id":"m","output_index":0,"content_index":0,"sequence_number":1}'
)
# Terminal event carries the full response (no [DONE] follows).
yield (
b'data: {"type":"response.completed","sequence_number":2,'
b'"response":{"id":"resp_1","object":"response","created_at":1,'
b'"model":"deepseek-v4-flash","status":"completed","output":[]}}'
)
# Anything after the terminal event must not be consumed.
yield b'data: {"type":"response.output_text.delta","delta":"ignored"}'
def close(self) -> None:
return None
async def fake_post(path: str, **kwargs: object) -> _Resp:
assert path == "/responses"
assert kwargs.get("stream") is True
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
stream = await client.responses(
ResponsesCreateParam(model="deepseek-v4-flash", input="hi"),
stream=True,
)
events = [event async for event in stream]
assert [event.type for event in events] == [
"response.output_text.delta",
"response.completed",
]
assert isinstance(events[1].response, dict)
assert events[1].response["status"] == "completed"
+131
View File
@@ -224,3 +224,134 @@ async def test_workspace_column_migration(tmp_path: object) -> None:
assert session.workspace == normalize_workspace(tmp_path)
await store.close()
async def _user_version(db_path: object) -> int:
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with engine.connect() as conn:
result = await conn.execute(text("PRAGMA user_version"))
return int(result.scalar() or 0)
finally:
await engine.dispose()
async def _session_columns(db_path: object) -> set[str]:
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with engine.connect() as conn:
result = await conn.execute(text("PRAGMA table_info(session)"))
return {str(row[1]) for row in result.fetchall()}
finally:
await engine.dispose()
async def _create_legacy_db(db_path: object, *, with_workspace: bool) -> None:
"""Build a pre-migration DB with a minimal session table (optional workspace)."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
async with engine.begin() as conn:
_ = await conn.execute(
text(
"CREATE TABLE user ("
"uid INTEGER PRIMARY KEY, name VARCHAR(48) UNIQUE, "
"email VARCHAR(255) UNIQUE, password_hash VARCHAR(256), "
"created_at DATETIME)"
)
)
workspace_col = ", workspace VARCHAR(1024)" if with_workspace else ""
_ = await conn.execute(
text(
"CREATE TABLE session ("
"sid INTEGER PRIMARY KEY, uid INTEGER, name VARCHAR(64), "
f"created_at DATETIME, updated_at DATETIME{workspace_col})"
)
)
_ = await conn.execute(
text(
"CREATE TABLE message ("
"mid INTEGER PRIMARY KEY, sid INTEGER, seq INTEGER, "
"data JSON, created_at DATETIME, updated_at DATETIME)"
)
)
await engine.dispose()
async def test_schema_migrations_stamp_version(tmp_path: object) -> None:
"""A fresh open creates the current schema and stamps PRAGMA user_version."""
from pathlib import Path
assert isinstance(tmp_path, Path)
db_path = tmp_path / "fresh.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close()
assert await _user_version(db_path) == 3
columns = await _session_columns(db_path)
assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
async def test_legacy_db_migrated_to_latest(tmp_path: object) -> None:
"""A pre-v0.1.0 session table runs all versioned steps and gets stamped."""
from pathlib import Path
assert isinstance(tmp_path, Path)
db_path = tmp_path / "legacy.db"
await _create_legacy_db(db_path, with_workspace=False)
assert await _user_version(db_path) == 0
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
session = await store.create_session(name="migrated")
await store.close()
assert await _user_version(db_path) == 3
columns = await _session_columns(db_path)
assert {"workspace", "provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert session.sid is not None
async def test_partially_migrated_legacy_db_noops_remaining_steps(tmp_path: object) -> None:
"""A DB upgraded incrementally (user_version=0, workspace present) is safe.
Versioned steps guard on their own columns, so an unstamped DB that already
has the v0.1.0 columns gets the rest applied and then stamped without
re-adding (or erroring on) existing columns.
"""
from pathlib import Path
assert isinstance(tmp_path, Path)
db_path = tmp_path / "partial.db"
await _create_legacy_db(db_path, with_workspace=True)
assert await _user_version(db_path) == 0
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
session = await store.create_session(name="partial", workspace=tmp_path)
await store.close()
assert await _user_version(db_path) == 3
columns = await _session_columns(db_path)
assert "workspace" in columns
assert {"provider_name", "model", "todo_stack", "last_prompt_tokens"} <= columns
assert session.workspace is not None
async def test_stamped_db_skips_migrations_on_reopen(tmp_path: object) -> None:
"""Reopening a current-schema DB does not re-run migrations or bump the stamp."""
from pathlib import Path
assert isinstance(tmp_path, Path)
db_path = tmp_path / "stamped.db"
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close()
assert await _user_version(db_path) == 3
store = await MemoryStore.open(DatabaseConfig(url=str(db_path)))
await store.close()
assert await _user_version(db_path) == 3
+132 -14
View File
@@ -5,6 +5,7 @@ import pytest
from plyngent.config.models import (
AnthropicProvider,
DeepseekProvider,
ModelConfig,
OpenAICompatibleProvider,
OpenAIProvider,
)
@@ -19,24 +20,22 @@ def test_openai_provider_defaults_base_url() -> None:
config = provider_to_openai_config(provider)
assert config.access_key_or_token == "sk-test"
assert config.base_url == "https://api.openai.com/v1"
from plyngent.agent.responses_client import ResponsesChatClient
from plyngent.lmproto.openai import OpenAIClient
client = create_client(provider)
assert isinstance(client, ResponsesChatClient)
assert hasattr(client, "chat_completions")
assert client._provider_tools == [{"type": "web_search"}]
assert isinstance(client, OpenAIClient)
assert client.kind == "responses"
def test_openai_provider_tools_passed_to_wrapper() -> None:
from plyngent.agent.responses_client import ResponsesChatClient
from plyngent.lmproto.openai import OpenAIClient
provider = OpenAIProvider(
access_key_or_token="sk-test",
provider_tools=[{"type": "web_search"}],
)
client = create_client(provider)
assert isinstance(client, ResponsesChatClient)
assert client._provider_tools == [{"type": "web_search"}]
assert isinstance(client, OpenAIClient)
def test_openai_compatible_requires_url() -> None:
@@ -65,16 +64,135 @@ def test_deepseek_openai_convention() -> None:
assert "deepseek-v4-pro" in provider.models
def test_deepseek_anthropic_convention_not_implemented() -> None:
def test_deepseek_ignores_legacy_convention_extra() -> None:
provider = DeepseekProvider(
access_key_or_token="sk-test",
extras={"convention": "anthropic"},
)
with pytest.raises(ProviderNotSupportedError, match="anthropic"):
_ = create_client(provider)
client = create_client(provider)
assert isinstance(client, DeepseekOpenAIClient)
def test_anthropic_not_implemented() -> None:
provider = AnthropicProvider(access_key_or_token="sk-test")
with pytest.raises(ProviderNotSupportedError, match="anthropic"):
_ = create_client(provider)
def test_deepseek_responses_convention() -> None:
from plyngent.lmproto.deepseek import DeepseekResponsesClient
provider = DeepseekProvider(access_key_or_token="sk-test", convention="responses")
client = create_client(provider)
assert isinstance(client, DeepseekResponsesClient)
assert client.kind == "responses"
assert provider_to_openai_config(provider).base_url == "https://api.deepseek.com"
def test_deepseek_default_and_legacy_conventions_stay_chat() -> None:
from plyngent.lmproto.deepseek import DeepseekResponsesClient
default_client = create_client(DeepseekProvider(access_key_or_token="sk-test"))
assert isinstance(default_client, DeepseekOpenAIClient)
assert not isinstance(default_client, DeepseekResponsesClient)
# extras.convention stays ignored (legacy location).
legacy_extra = DeepseekProvider(
access_key_or_token="sk-test",
extras={"convention": "anthropic"},
)
assert isinstance(create_client(legacy_extra), DeepseekOpenAIClient)
def test_deepseek_anthropic_convention() -> None:
from plyngent.lmproto.anthropic import AnthropicClient
from plyngent.lmproto.deepseek import DeepseekAnthropicClient
provider = DeepseekProvider(access_key_or_token="sk-test", convention="anthropic")
client = create_client(provider)
assert isinstance(client, DeepseekAnthropicClient)
assert isinstance(client, AnthropicClient)
assert client.kind == "messages"
assert client.session.base_url == "https://api.deepseek.com/anthropic"
def test_deepseek_anthropic_convention_custom_url() -> None:
from plyngent.lmproto.deepseek import DeepseekAnthropicClient
provider = DeepseekProvider(
access_key_or_token="sk-test",
url="https://gateway.example/anthropic",
convention="anthropic",
)
client = create_client(provider)
assert isinstance(client, DeepseekAnthropicClient)
assert client.session.base_url == "https://gateway.example/anthropic"
def test_deepseek_model_level_anthropic_convention() -> None:
from plyngent.lmproto.deepseek import DeepseekAnthropicClient, DeepseekOpenAIClient
provider = DeepseekProvider(
access_key_or_token="sk-test",
models={
"claude-proxy": ModelConfig(convention="anthropic"),
"deepseek-v4-flash": ModelConfig(),
},
)
proxy = create_client(provider, model="claude-proxy")
assert isinstance(proxy, DeepseekAnthropicClient)
assert proxy.session.base_url == "https://api.deepseek.com/anthropic"
flash = create_client(provider, model="deepseek-v4-flash")
assert isinstance(flash, DeepseekOpenAIClient)
def test_deepseek_model_level_convention_override() -> None:
from plyngent.lmproto.deepseek import DeepseekResponsesClient
provider = DeepseekProvider(
access_key_or_token="sk-test",
models={
"deepseek-v4-flash": ModelConfig(convention="responses"),
"deepseek-v4-pro": ModelConfig(),
},
)
flash = create_client(provider, model="deepseek-v4-flash")
assert isinstance(flash, DeepseekResponsesClient)
assert flash.kind == "responses"
pro = create_client(provider, model="deepseek-v4-pro")
assert isinstance(pro, DeepseekOpenAIClient)
def test_anthropic_client_created() -> None:
from plyngent.lmproto.anthropic import AnthropicClient
provider = AnthropicProvider(access_key_or_token="sk-test", timeout=30)
client = create_client(provider)
assert isinstance(client, AnthropicClient)
assert client.kind == "messages"
# timeout is passed through to the niquests session
assert client.session.timeout == 30
def test_model_level_preset_url_overrides_parent() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://gateway.example/v1",
models={
"gpt": ModelConfig(preset="openai", url="https://api.openai.com/v1"),
"qwen": ModelConfig(),
},
)
openai_client = create_client(provider, model="gpt")
assert openai_client.kind == "responses"
assert provider_to_openai_config(provider, model="gpt").base_url == "https://api.openai.com/v1"
compat_client = create_client(provider, model="qwen")
assert isinstance(compat_client, OpenAICompatibleClient)
assert provider_to_openai_config(provider, model="qwen").base_url == "https://gateway.example/v1"
def test_model_level_anthropic_preset() -> None:
from plyngent.lmproto.anthropic import AnthropicClient
provider = OpenAICompatibleProvider(
access_key_or_token="sk-test",
url="https://gateway.example/v1",
models={"claude": ModelConfig(preset="anthropic", url="https://api.anthropic.com/v1")},
)
client = create_client(provider, model="claude")
assert isinstance(client, AnthropicClient)
assert client.kind == "messages"
+1 -1
View File
@@ -90,7 +90,7 @@ def test_registration_source_context() -> None:
def test_tool_tags_reject_neither_surface() -> None:
try:
@tool(tags=ToolTag.YOLO, register=False) # type: ignore[arg-type]
@tool(tags=ToolTag.YOLO, register=False)
async def bad() -> str:
return "x"
+27 -6
View File
@@ -25,7 +25,7 @@ async def test_choose_user_tool_index() -> None:
json.dumps(
{
"question": "Pick",
"options": json.dumps(["alpha", "beta"]),
"options": ["alpha", "beta"],
"allow_custom": False,
}
),
@@ -33,23 +33,44 @@ async def test_choose_user_tool_index() -> None:
assert out == "alpha"
async def test_choose_user_bad_options() -> None:
async def test_choose_user_dict_options() -> None:
backend = ScriptedBackend(["1"])
with temporary_backend(backend):
registry = ToolRegistry([choose_user])
out = await registry.execute(
"ask_user_choice",
json.dumps(
{
"question": "Pick",
"options": [{"label": "yes", "description": "affirm"}],
}
),
)
assert out == "yes"
async def test_choose_user_invalid_options() -> None:
registry = ToolRegistry([choose_user])
out = await registry.execute(
"ask_user_choice",
json.dumps({"question": "Pick", "options": "not-json"}),
json.dumps({"question": "Pick", "options": [42]}),
)
assert out.startswith("error:")
assert "error" in out
async def test_form_user_tool() -> None:
backend = ScriptedBackend(["ncbm"], confirms=[True])
fields = json.dumps([{"name": "user", "prompt": "User?"}])
with temporary_backend(backend):
registry = ToolRegistry([form_user])
out = await registry.execute(
"ask_user_form",
json.dumps({"title": "Setup", "fields": fields, "confirm_submit": True}),
json.dumps(
{
"title": "Setup",
"fields": [{"name": "user", "prompt": "User?"}],
"confirm_submit": True,
}
),
)
assert json.loads(out) == {"user": "ncbm"}
+7 -6
View File
@@ -8,11 +8,12 @@ from plyngent.tools.grants import clear_grants, has_grant, hydrate_grants, persi
from plyngent.tools.view import MemoryViewStore, session_data_view
async def test_yolo_only_skips_yolo_tagged_tools() -> None:
async def test_danger_overrides_yolo_tag() -> None:
"""Decorator YOLO tag does not bypass confirm when danger flags the call."""
calls: list[str] = []
@tool(tags=ToolTag.LOCAL | ToolTag.YOLO, register=False)
async def yolo_ok() -> str:
async def yolo_tool() -> str:
return "y"
@tool(tags=ToolTag.LOCAL, register=False)
@@ -27,15 +28,15 @@ async def test_yolo_only_skips_yolo_tagged_tools() -> None:
return True
reg = ToolRegistry(
[yolo_ok, no_yolo],
[yolo_tool, no_yolo],
danger=danger,
on_confirm=confirm,
yolo=True,
)
assert await reg.execute("yolo_ok", "{}") == "y"
assert calls == []
assert await reg.execute("yolo_tool", "{}") == "y"
assert calls == ["yolo_tool"]
assert await reg.execute("no_yolo", "{}") == "n"
assert calls == ["no_yolo"]
assert calls == ["yolo_tool", "no_yolo"]
async def test_trustable_grant_once() -> None:
+78 -1
View File
@@ -2,21 +2,60 @@ from __future__ import annotations
from pathlib import Path
from plyngent.tools.file import edit_lineno, read_file, write_file
from plyngent.agent import reset_lineno_tracker
from plyngent.tools.file import edit_lineno, edit_replace, read_file, write_file
from tests.test_tools.helpers import call_sync
def _read_lineno(path: str) -> str:
"""Read file with line numbers so edit_lineno allows the edit."""
_ = reset_lineno_tracker()
return call_sync(read_file, path, with_lineno=True)
def test_edit_lineno_replace_middle(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "a.txt", "one\ntwo\nthree\nfour\n")
_ = _read_lineno("a.txt")
out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n")
assert "replaced lines 2-3" in out
assert call_sync(read_file, "a.txt") == "one\nTWO\nTHREE\nfour\n"
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "r.txt", "a\nb\nc\nd\ne\n")
_ = reset_lineno_tracker()
# Only lines 2-4 are read; editing line 1 or 5 must be rejected.
_ = call_sync(read_file, "r.txt", offset=1, limit=3, with_lineno=True)
out = call_sync(edit_lineno, "r.txt", 1, 2, "X\n")
assert "not read" in out and "1" in out
out = call_sync(edit_lineno, "r.txt", 4, 5, "Y\n")
assert "not read" in out and "5" in out
# Fully read range still edits.
out = call_sync(edit_lineno, "r.txt", 2, 3, "B\nC\n")
assert "replaced lines 2-3" in out
def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "ap.txt", "a\nb\nc\n")
_ = reset_lineno_tracker()
# Only lines 1-2 read; appending after line 3 requires line 3 read.
_ = 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
# 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")
assert "appended" in out
assert call_sync(read_file, "ap.txt") == "a\nb\nc\nd\n"
def test_edit_lineno_delete_range(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "b.txt", "a\nb\nc\n")
_ = _read_lineno("b.txt")
out = call_sync(edit_lineno, "b.txt", 2, 2, "")
assert "replaced" in out
assert call_sync(read_file, "b.txt") == "a\nc\n"
@@ -25,6 +64,7 @@ def test_edit_lineno_delete_range(workspace: object) -> None:
def test_edit_lineno_append(workspace: object) -> None:
assert isinstance(workspace, Path)
_ = call_sync(write_file, "c.txt", "only\n")
_ = _read_lineno("c.txt")
out = call_sync(edit_lineno, "c.txt", 2, 2, "more\n")
assert "appended" in out
assert call_sync(read_file, "c.txt") == "only\nmore\n"
@@ -33,5 +73,42 @@ def test_edit_lineno_append(workspace: object) -> None:
def test_edit_lineno_invalid(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "d.txt", "x\n")
_ = _read_lineno("d.txt")
assert "start_line" in call_sync(edit_lineno, "d.txt", 0, 1, "y")
assert "end_line" in call_sync(edit_lineno, "d.txt", 2, 1, "y")
def test_edit_lineno_rejects_without_prior_lineno_read(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "e.txt", "hello\nworld\n")
_ = reset_lineno_tracker()
out = call_sync(edit_lineno, "e.txt", 1, 1, "hi\n")
assert "read" in out and "with_lineno=true" in out
def test_edit_lineno_requires_rerun_after_write(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "f.txt", "one\ntwo\nthree\n")
_ = _read_lineno("f.txt")
_ = call_sync(write_file, "f.txt", "changed\n")
out = call_sync(edit_lineno, "f.txt", 1, 1, "x\n")
assert "not read" in out
def test_edit_lineno_requires_rerun_after_edit_replace(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "g.txt", "one\ntwo\nthree\n")
_ = _read_lineno("g.txt")
_ = call_sync(edit_replace, "g.txt", "two", "TWO")
out = call_sync(edit_lineno, "g.txt", 2, 2, "x\n")
assert "not read" in out
def test_edit_lineno_invalidates_after_successful_edit(workspace: object) -> None:
del workspace
_ = call_sync(write_file, "h.txt", "a\nb\nc\n")
_ = _read_lineno("h.txt")
_ = call_sync(edit_lineno, "h.txt", 2, 2, "B\n")
# After the edit, line numbers are stale — must re-read before editing again.
out = call_sync(edit_lineno, "h.txt", 2, 2, "C\n")
assert "not read" in out
+28
View File
@@ -132,6 +132,34 @@ def test_classify_ips() -> None:
assert classify_ip_strings(["8.8.8.8", "10.0.0.1"]) is HostClass.PRIVATE
def test_ssrf_assume_public_cidrs_fake_ip() -> None:
from plyngent.tools.net.policy import (
clear_ssrf_assume_public_cidrs,
parse_ssrf_assume_public_cidrs,
set_ssrf_assume_public_cidrs,
)
# Clash Fake-IP style address is private without exemption.
assert classify_ip_strings(["198.18.0.1"]) is HostClass.PRIVATE
nets = set_ssrf_assume_public_cidrs(["198.18.0.0/15"])
assert nets
assert classify_ip_strings(["198.18.0.1"]) is HostClass.PUBLIC
# Explicit assume_public overrides empty context.
clear_ssrf_assume_public_cidrs()
assert classify_ip_strings(["198.18.0.1"]) is HostClass.PRIVATE
assert (
classify_ip_strings(
["198.18.0.1"],
assume_public=parse_ssrf_assume_public_cidrs(["198.18.0.0/15"]),
)
is HostClass.PUBLIC
)
# Metadata never becomes public via assume list.
set_ssrf_assume_public_cidrs(["169.254.0.0/16", "198.18.0.0/15"])
assert classify_ip_strings(["169.254.169.254"]) is HostClass.FORBIDDEN
clear_ssrf_assume_public_cidrs()
def test_soft_confirm_reason_matrix() -> None:
assert soft_confirm_reason(method="GET", url="https://ex.com/", scheme="https", body_present=False) is None
http_reason = soft_confirm_reason(method="GET", url="http://ex.com/", scheme="http", body_present=False)