mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-08 16:37:55 +08:00
Compare commits
50 Commits
f3cefccefc
...
c1dfdc8478
| Author | SHA1 | Date | |
|---|---|---|---|
| c1dfdc8478 | |||
| 3449f3a77d | |||
| a834984bcc | |||
| 8954d855d5 | |||
| a198fa9d41 | |||
| 439c24a1ce | |||
| 6e4d229f6f | |||
| f6072ce2a6 | |||
| 6932937f87 | |||
| 4b7cc93917 | |||
| bfafe74a6f | |||
| db6d2a83f4 | |||
| 8ad33af652 | |||
| a877dba8c1 | |||
| 3fe1c4cf91 | |||
| 49a3ef0004 | |||
| 19ac4533d4 | |||
| 56ac8d17d7 | |||
| 0cb7a29f21 | |||
| 1d4e48f6f3 | |||
| 626012e92f | |||
| 5198a8da20 | |||
| e29e66dbc7 | |||
| 9aea91e77c | |||
| 06f1b386a0 | |||
| 556eca66db | |||
| 44a5f047ba | |||
| 3a671f73ea | |||
| de7cae83e4 | |||
| ad58d6cdc6 | |||
| ef3594ad62 | |||
| f5b566b371 | |||
| 39ecd55ddd | |||
| 60f9c9b56d | |||
| 904ac3d982 | |||
| f60fd876c6 | |||
| f0c27bf366 | |||
| 4562eaa1cb | |||
| 6eaadd6ebf | |||
| 49e878fe3a | |||
| 200bde0bfb | |||
| 0ce9ed84a0 | |||
| a83c67ba6d | |||
| 3a5c715e8f | |||
| fc98b080e1 | |||
| 9600d2f757 | |||
| c7ee9b0063 | |||
| 3b72698e14 | |||
| 68025ca626 | |||
| 0ca92dfd7e |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]: ...
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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'} "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user