mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
Compare commits
44 Commits
1cead73320
..
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a89543fd35 | |||
| 9296242881 | |||
| 5a7bd55f2d | |||
| c56449bfce | |||
| 9fc74e1866 | |||
| 6e8368c169 | |||
| e0e19fde8f | |||
| 9fac1ecfc0 | |||
| 432b703b54 | |||
| bf8203ae37 | |||
| 4b5f67f5f3 | |||
| 933d5385b9 | |||
| 2bb6e06171 | |||
| 19ab86f590 | |||
| e877df9d94 | |||
| 27a901083a | |||
| a0d39bba6d | |||
| e2ee937d8f | |||
| af232283a4 | |||
| 21f59ea33c | |||
| 45246bf949 | |||
| 1fa41c9fde | |||
| 88b93a8dfe | |||
| 0da6e8da8d | |||
| c06fb57e72 | |||
| 932b4f0a7f | |||
| 19e247d739 | |||
| 135c28a622 | |||
| 2b3b2073a0 | |||
| e05dc5127a | |||
| 768b310a0b | |||
| 66b771f87d | |||
| ec5340bdce | |||
| 35dbd30328 | |||
| 60bc5957a7 | |||
| fb8ef88d9f | |||
| 445aba7e5f | |||
| ffd4dbbe26 | |||
| 0c9bb2e067 | |||
| 7cd5fcc576 | |||
| 49ddf1c3c1 | |||
| 7d9b979604 | |||
| 4b1cb84256 | |||
| cecd5e5ae3 |
@@ -50,17 +50,19 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
|
||||
- **`openai_compatible/config.py`** — `OpenAIConfig` (token + base URL).
|
||||
- **`openai/model.py`** — OpenAI Responses API (`ResponsesCreateParam`, `Response`, function_call items, stream events).
|
||||
- **`openai/client.py`** — platform `OpenAIClient` (`kind="responses"`): chat completions + `responses` / `get_response` / `delete_response`.
|
||||
- **`anthropic/model.py`** — Anthropic Messages request/response + SSE event models.
|
||||
- **`anthropic/model.py`** — Anthropic Messages request/response + SSE event models; the response content union also carries the server-tool blocks DeepSeek's compat returns (`thinking`, `server_tool_use`, `web_search_tool_result` with its verbatim `encrypted_content`), and `tools` accepts opaque server-tool dicts.
|
||||
- **`anthropic/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 `[]`).
|
||||
- **DeepSeek** — `DeepseekOpenAIClient` (`kind="chat_completions"`); models add `reasoning_content`, `prefix`, `ThinkingOptions`, and it injects the `thinking` flag (`enabled`, or `disabled` for `reasoning_effort = "none"`) after encoding, plus an empty `reasoning_content` on any assistant message that carries none (thinking mode refuses any round — a call or a plain answer — whose reasoning never came back). Config default model ids: `deepseek-flash`, `deepseek-v4-pro`. `DeepseekResponsesClient` (`kind="responses"`) extends the OpenAI Responses client for `convention = "responses"` (SSE stops at terminal events — DeepSeek sends no `[DONE]`). `DeepseekAnthropicClient` (`kind="messages"`) extends `AnthropicClient` for `convention = "anthropic"` (base `https://api.deepseek.com/anthropic`; `models()` returns `[]`).
|
||||
|
||||
### 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). `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). `[mcp]` (`McpConfig`): `servers.<name>` stdio server definitions (`McpServerConfig`: `command`/`args`/`env`/`cwd`/`timeout`/`read_only`, `url` reserved for streamable HTTP) + `disable` gate; defining a server opts it in. `[skills]` (`SkillsConfig`): `paths` (explicit roots first), `discover` (`plyngent` user dir + `claude` dirs) with `include_project_roots`, `inject_catalog` / `max_catalog_skills`, `enabled`, and `allow_write` (standing write grant); a malformed section degrades to defaults like `[agent]` / `[mcp]`.
|
||||
|
||||
`reasoning.py`: thinking strength as `reasoning_effort` (`ReasoningEffortConfig`: `""` = send nothing | `none` … `high` … `max`) and `thinking_budget_tokens`, settable on `[agent]`, a provider, or a model entry — `resolve_reasoning(agent, provider, model)` merges the layers per field (model > provider > agent) and clamps `max` (DeepSeek chat only) to `xhigh` for every other surface, returning a `ReasoningConfig` the agent loop and `create_client` consume.
|
||||
|
||||
### Runtime (`runtime/`)
|
||||
|
||||
`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`.
|
||||
`create_client(provider, *, model=None, reasoning=None)` maps config `Provider` → protocol client by effective preset (model-level `preset`/`url` overrides apply); `reasoning` (resolved `ReasoningConfig`) carries the Anthropic `thinking_budget_tokens` onto `AnthropicConfig` — the other surfaces read the effort off the request param — and is derived from the provider/model layers alone when omitted. `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`.
|
||||
|
||||
MCP (`runtime/mcp_client.py`): `McpManager` owns one `McpServerConnection` per `[mcp]` server — a spawned stdio subprocess speaking newline-delimited JSON-RPC 2.0: initialize handshake, `tools/list` (cursor pagination), `tools/call`, `notifications/tools/list_changed` → `tools_stale`. Spawn/handshake failures record `connection.error` (status string), never raise; `ensure_started` / `restart(config)` / `aclose` / `refresh_tools`; `statuses()` → `(server, status, tool_count)` rows; `instructions()` → `(server, text)` for every connected server that returned optional `InitializeResult.instructions` (usage guidance captured per connection); per-request timeout from config (default 30s).
|
||||
|
||||
@@ -75,16 +77,16 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
### Agent (`agent/`)
|
||||
|
||||
- **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`.
|
||||
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
|
||||
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call, and the blocks are read (see `lmproto/anthropic/model.py`) but never executed.
|
||||
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results must be passed back verbatim if they are kept at all) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet).
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`.
|
||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config).
|
||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
|
||||
- **`/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), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `[skills] inject_catalog` (fold the skill catalog — name/source/description only — the same way), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `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.
|
||||
- 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), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `[skills] inject_catalog` (fold the skill catalog — name/source/description only — the same way), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `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/`)
|
||||
|
||||
@@ -96,11 +98,12 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
- **`process`**: `run_argv` (argv, no shell, timeout, optional stdin/env); `run_argv_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. The prompt accepts `y`/empty (once), `n`, or `yyy` = stop asking for the rest of the turn (`cli.limits` module flag, reset by `run_turn_with_retries`, `/btw`, session switches, and chat exit). `--auto-continue` / `[agent] auto_continue_limits` set the process-wide default (covers one-shot/PTY); the flag does not imply YOLO and vice versa.
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) — and detached runners (`nohup`), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
|
||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for interpreters — bare shells/REPLs, scripts, and `-c` one-liners alike (`-c` is code only for an interpreter, so `grep -c` / `od -c` stay unflagged; argv + code preview) —, detached runners (`nohup`), and wrapper options that carry a command line (`env -S "…"`, `su -c "…"`, whose text is printed under its own `env -S:` label), each resolved past env wrappers and `<launcher> run`; deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
|
||||
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
||||
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core); `wait` (line prompt with timeout; Enter disturbs). Container args are shape-checked (`tools/chat/shape.py`): a mis-typed string `options`/`fields` (or a non-string `question`) returns an explained error — `options="a, b"` used to iterate characters into a per-character menu. `wait`'s `duration` accepts numeric strings (`"5"` = 5s) and rejects booleans/text/containers.
|
||||
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer`/`user` = prose nag; `synthetic_tool` = forged `todo_list` call + **real** `stack.render()` result + ToolCall/Result events; `none`).
|
||||
- **`net` / `fetch`**: HTTP GET/POST/PUT/DELETE via niquests (isolated session, not the LLM client). Manual redirects with per-hop SSRF checks (`asyncio` `getaddrinfo`). Private/loopback hosts need instance policy grant (CLI timed confirm; **not** YOLO). Public cleartext HTTP and mutating methods soft-confirm (`YOLO`/`TRUSTABLE`). HTTPS→HTTP redirects blocked unless `allow_http_downgrade`. `user_agent` tool arg (or headers) is never replaced by the default when provided. Caps: body bytes/chars, request body size.
|
||||
- **`net` / `web_search`** (`tools/net/search.py`, `tools/net/bing.py`): the hosted search a DeepSeek provider cannot use, as a local tool. `build_web_search_tool(backends)` closes over the sources named by `provider.search_sources` and asks them **in fallback order** (`search_in_order`): `DeepseekSearchBackend` (one `/messages` turn with the server tool, `lmproto/deepseek/anthropic/search.py`) and/or `BingSearchBackend` (Bing's RSS results, snippets included, fetched through the same client as `fetch`). The handler returns one `title — url` entry per hit (plus the snippet when the source has one); a source that raises or finds nothing is skipped and reported only if every source fails. Registered by the CLI host only when `config.routing.local_web_search` says so: a DeepSeek provider whose `provider_tools` ask for `web_search`, on a surface that ignores hosted tools (`convention` other than `anthropic`), with a non-empty `search_sources`. Tags LOCAL|READ_ONLY.
|
||||
- **`mcp`** (`tools/mcp.py`): register one server's advertised tools into the catalog as namespaced `mcp__<server>__<tool>` definitions with `ToolSource(kind="mcp", plugin_id=server)`; tags LOCAL (+ READ_ONLY when the server config sets `read_only`) — never YOLO/TRUSTABLE. `register_mcp_tools` purges prior MCP entries then re-registers every connected server (hosts call it on tool-registry rebuild); connections must already be started by `McpManager`.
|
||||
- **`skills`** (`tools/skills.py`): discovery, reading, and writing of skill directories (`plyngent.skills` owns roots, the `SKILL.md` frontmatter subset, the mtime-cached store, and the inside-the-skill path rule). `skill_list` (names, source root, directory, description, shadowed copies, frontmatter notes), `skill_read` (a `SKILL.md` by name, or one bundled file — reuses `read_file` for ranges/line numbers/`truncate_token`), `skill_search` (regex over every visible skill via `regex_files`, hits rebased on `<skill>/<file>`), `skill_create` (new `SKILL.md` + optional `files`), `skill_edit` (replace / `append` / rewrite one file; a rewritten `SKILL.md` is re-parsed and notes a lost description). Reads need no grant (the host publishes the roots as `WorkspacePolicy.static_read`, so `read_file`/`regex_files` see them while writes stay denied); writes go through **`SkillWritePolicy`** on the instance — denylist first, then `SessionState.skill_write_roots` (a `session` answer), then `[skills].allow_write`, then the host's confirm hook (CLI `prompt_skill_write_confirm`: `o`nce/`s`ession/`n`o, timed, default deny, **not** skipped by YOLO); a host with no hook denies. Grants are per **skill directory**; `[skills].enabled = false` drops the tools from the registry (`SKILL_TOOL_NAMES`).
|
||||
- **`DEFAULT_TOOLS` / catalog**: access + file + process + vcs + chat + todo + skills + net (`fetch`) for local surface. `ToolTag.READ_ONLY` marks non-mutating tools (`read_file`, `get_truncated`, `listdir`, `glob_paths`, `regex_files`, `tree`, `todo_list`, `wait`, `ask_user_*`, `vcs_*`, `skill_list`/`skill_read`/`skill_search`, `fetch`); `ToolRegistry.clone(read_only_only=True)` selects just those for safe side turns / planning, and `read_only_context()` (in `tools/context.py`) makes `fetch` accept GET only.
|
||||
@@ -118,8 +121,8 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
|
||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
|
||||
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept** (side effects not re-run on `/retry`); only unfinished assistant rolled back; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call before the first result (parallel batch), or a prompt box printed while the tool runs, *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call.
|
||||
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
|
||||
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
|
||||
|
||||
@@ -131,6 +134,12 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||
|
||||
Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` ignored). Prefer PEP 695 aliases except where msgspec requires plain assignment (`Unset`).
|
||||
|
||||
### Function complexity (`C901`)
|
||||
|
||||
Ruff's `C901` stays at its default **10** (no `mccabe` section in `pyproject.toml`): one decision point per `if` / `elif` / `for` / `while` / `except` handler, one per `match` case past the first, plus one per nested `def`. Boolean operators, ternaries, comprehensions, `with`, and `assert` are free — so a wide flat guard is fine and a case-per-branch dispatcher is not. That is the intent: the few functions over it here keep a `# noqa: C901` with the reason after the codes (`— one branch per event type`), because the count is what those functions are. `PLR0912` is ignored (same branches, looser default, and `C901` already flags every function it would); `PLR0911` / `PLR0915` stay — returns and statements are different axes.
|
||||
|
||||
A nested function is counted **twice**: its decision points are added to the enclosing function *and* reported on the nested function itself (`def outer` with one helper `if` and one own `if` scores 3; the helper scores 2 on its own). A function that only scores high because of its closures is asking for those to be hoisted to module level, not for a wider limit.
|
||||
|
||||
## Roadmap notes (single-user → platform)
|
||||
|
||||
- **Phase D (context quality)**: soft char budget, request compact, `/compact`, richer errors/cancel, workspace sessions. Context size is **char estimate** plus optional API usage when reported.
|
||||
|
||||
@@ -154,6 +154,11 @@ max_context_tokens = 200000
|
||||
# allow_paths = { "/data/datasets" = "read", "/tmp/build" = "exec" }
|
||||
# Auto-raise tool/PTY limits without prompting (same as answering yyy).
|
||||
# auto_continue_limits = false
|
||||
# Thinking strength: "" (provider default) | none | minimal | low | medium |
|
||||
# high | xhigh | max. See "Thinking strength" below; providers and single
|
||||
# models may override either value.
|
||||
# reasoning_effort = "medium"
|
||||
# thinking_budget_tokens = 8000 # Anthropic only; overrides the level's budget
|
||||
|
||||
# Optional skills (instruction directories). See doc/skills.md.
|
||||
# [skills]
|
||||
@@ -172,6 +177,20 @@ max_context_tokens = 200000
|
||||
|
||||
Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_argv`, PTY, policy confirm) are separate.
|
||||
|
||||
**Thinking strength** (`reasoning_effort`) can be set on `[agent]`, on a provider, or on a single model: the most specific layer wins, per field, so a model may override only the effort or only the budget. `""` sends nothing and lets the provider apply its own default. Each API surface maps the value onto its own wire field — chat completions send `reasoning_effort`, Responses send `reasoning.effort`, and Anthropic has no effort level at all, so it sends `thinking` with the token budget that level maps onto (`minimal` 1024 … `xhigh`/`max` 32768; `none` disables thinking). `max` is DeepSeek's own top level on chat completions and clamps to `xhigh` everywhere else. `thinking_budget_tokens` overrides that mapping; either way the request stays valid: the budget never drops below 1024, and when it would swallow the output limit `max_tokens` grows so the answer still has room. DeepSeek chat keeps `thinking.type = "enabled"` unless the effort is `none`.
|
||||
|
||||
```toml
|
||||
[providers.deepseek]
|
||||
reasoning_effort = "high" # provider default
|
||||
|
||||
[providers.deepseek.models]
|
||||
"deepseek-v4-pro" = { text = true, reasoning_effort = "max" } # per-model wins
|
||||
|
||||
[providers.anthropic]
|
||||
reasoning_effort = "medium"
|
||||
thinking_budget_tokens = 16000 # explicit Anthropic thinking budget
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
**Skills** (instruction directories with a `SKILL.md`): discovered under `[skills].paths`, our own user skills directory, and other harnesses' skill directories (`~/.claude/skills`, plus `<workspace>/.claude/skills` when `include_project_roots`). The catalog — name, source, description — is folded into the system prompt when tools are on; the model reads a `SKILL.md` with `skill_read`, searches with `skill_search`, and creates or updates skills with `skill_create` / `skill_edit` behind a timed `once`/`session`/`no` grant (`[skills].allow_write = true` for a standing one; the path denylist still wins and `--yes`/`/yolo` never skips it). Reading needs no grant: skill roots are read-only roots, so `read_file` / `regex_files` see them too while writes stay denied. Details: [doc/skills.md](doc/skills.md).
|
||||
@@ -184,10 +203,10 @@ Supported provider presets today:
|
||||
|--------|------------------------|--------|
|
||||
| `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` |
|
||||
| `deepseek` | Chat Completions (DeepSeek) | Default models `deepseek-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-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:
|
||||
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-flash` over Responses while `deepseek-v4-pro` stays Chat Completions:
|
||||
|
||||
```toml
|
||||
[providers.deepseek]
|
||||
@@ -195,7 +214,7 @@ preset = "deepseek"
|
||||
access_key_or_token = "sk-..."
|
||||
|
||||
[providers.deepseek.models]
|
||||
"deepseek-v4-flash" = { text = true, convention = "responses" }
|
||||
"deepseek-flash" = { text = true, convention = "responses" }
|
||||
"deepseek-v4-pro" = { text = true }
|
||||
```
|
||||
|
||||
@@ -203,8 +222,8 @@ 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
|
||||
`claude-sonnet*` (and unknown ids) to `deepseek-flash` server-side; real
|
||||
DeepSeek 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).
|
||||
@@ -289,7 +308,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
|
||||
| `/quit` | Leave the REPL |
|
||||
|
||||
User messages are saved immediately. On API error or Ctrl+C, partial assistant/tool output is discarded but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s.
|
||||
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s.
|
||||
|
||||
Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancels the retry. Model-initiated prompts run off the event loop: a `wait` prompt is cancelled by Ctrl+C (the tool reports `cancelled by user` and the turn continues), while `ask_user_*` and confirm prompts ignore it with a one-line hint — answer them to continue. Ctrl+C never exits the REPL (use Ctrl+D or `/quit`), and MCP servers run in their own process group, so it never kills them either.
|
||||
|
||||
|
||||
@@ -96,6 +96,20 @@ max_context_tokens = 200000
|
||||
# directive_reminder_tokens = 100000
|
||||
# directive_reminder_text = ""
|
||||
|
||||
# Thinking strength, resolved as [agent] < provider < model (the most specific
|
||||
# layer wins, per field). "" = send nothing and keep the provider's own default.
|
||||
# none | minimal | low | medium | high | xhigh | max
|
||||
# ``max`` is DeepSeek's own top level on chat completions and clamps to ``xhigh``
|
||||
# everywhere else. The agent maps the value onto each API surface: chat
|
||||
# completions send ``reasoning_effort``, Responses send ``reasoning.effort``, and
|
||||
# Anthropic — which has no effort level — sends ``thinking`` with the token
|
||||
# budget the level maps onto (minimal 1024 … xhigh/max 32768, none = disabled).
|
||||
# reasoning_effort = "medium"
|
||||
# Explicit Anthropic thinking budget in tokens, overriding that mapping; the
|
||||
# request stays valid either way (floor 1024, and ``max_tokens`` grows when the
|
||||
# budget would leave no room for the answer).
|
||||
# thinking_budget_tokens = 8000
|
||||
|
||||
# Optional: custom compact/summarisation prompts (empty = built-in defaults).
|
||||
# compact_system_prompt = ""
|
||||
# compact_user_prefix = "Summarize the following conversation:\n\n{transcript}"
|
||||
@@ -120,6 +134,9 @@ access_key_or_token = "sk-replace-me"
|
||||
# preset = "openai"
|
||||
# access_key_or_token = "sk-replace-me"
|
||||
# url = "https://api.openai.com/v1"
|
||||
# # Thinking strength for every model of this provider ("" = inherit [agent];
|
||||
# # a model entry may override it). Values as in the [agent] section above.
|
||||
# reasoning_effort = "medium"
|
||||
# # Hosted/provider-side tools (OpenAI runs them; not local ToolRegistry).
|
||||
# # Default if omitted: [{ type = "web_search" }]. Use [] to disable.
|
||||
# # Examples: web_search, file_search (+ vector_store_ids), code_interpreter, mcp.
|
||||
@@ -135,6 +152,8 @@ access_key_or_token = "sk-replace-me"
|
||||
# # `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" }
|
||||
# # A model entry may also set its own thinking strength, e.g. skip thinking for
|
||||
# # a cheap model: "gpt-5.4-nano" = { text = true, reasoning_effort = "none" }
|
||||
|
||||
# --- Anthropic (Messages API) ---
|
||||
# [providers.anthropic]
|
||||
@@ -142,13 +161,17 @@ access_key_or_token = "sk-replace-me"
|
||||
# access_key_or_token = "sk-ant-replace-me"
|
||||
# # url defaults to https://api.anthropic.com/v1 when empty
|
||||
# timeout = { connect = 10, read = 600 }
|
||||
# # Anthropic has no effort level: an effort becomes a thinking token budget
|
||||
# # (see the [agent] section above). Set the budget explicitly to pin it:
|
||||
# reasoning_effort = "high"
|
||||
# thinking_budget_tokens = 16000
|
||||
#
|
||||
# [providers.anthropic.models]
|
||||
# "claude-sonnet-4-20250514" = { text = true }
|
||||
# "claude-opus-4-20250514" = { text = true }
|
||||
# "claude-opus-4-20250514" = { text = true, reasoning_effort = "xhigh" }
|
||||
|
||||
# --- DeepSeek (OpenAI-compatible / Responses / Anthropic conventions) ---
|
||||
# Default models if [providers.*.models] is omitted: deepseek-v4-flash, deepseek-v4-pro.
|
||||
# Default models if [providers.*.models] is omitted: deepseek-flash, deepseek-v4-pro.
|
||||
# [providers.deepseek]
|
||||
# preset = "deepseek"
|
||||
# access_key_or_token = "sk-replace-me"
|
||||
@@ -157,14 +180,16 @@ access_key_or_token = "sk-replace-me"
|
||||
# # Convention selects the API surface:
|
||||
# # "" / "openai" → OpenAI-compatible chat completions (default)
|
||||
# # "responses" → OpenAI Responses API (POST /responses; today only
|
||||
# # deepseek-v4-flash supports it)
|
||||
# # deepseek-flash supports it)
|
||||
# # "anthropic" → Anthropic Messages API (POST /messages on
|
||||
# # https://api.deepseek.com/anthropic; claude-* model ids
|
||||
# # map server-side to deepseek-v4-*)
|
||||
# # map server-side to deepseek-v4-pro / deepseek-flash)
|
||||
# convention = "responses"
|
||||
# # Legacy location (parsed but ignored; prefer the typed field above):
|
||||
# # extras = { convention = "openai" }
|
||||
# # Thinking strength for this provider; per-model entries below may override it.
|
||||
# # reasoning_effort = "high"
|
||||
#
|
||||
# [providers.deepseek.models]
|
||||
# "deepseek-v4-flash" = { text = true, convention = "responses" }
|
||||
# "deepseek-v4-pro" = { text = true }
|
||||
# "deepseek-flash" = { text = true, convention = "responses" }
|
||||
# "deepseek-v4-pro" = { text = true, reasoning_effort = "max" }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
groups = ["default", "dev"]
|
||||
strategy = ["inherit_metadata"]
|
||||
lock_version = "4.5.1"
|
||||
content_hash = "sha256:94dc16bf6ed12e2fcd7a109b500e9b2d7e39f224b60472d64ecb1d3436ddbb82"
|
||||
content_hash = "sha256:b69c90f70f1118ebb9080c3e0190e8b4d85dd4e5999466b617430399d0ab3125"
|
||||
|
||||
[[metadata.targets]]
|
||||
requires_python = "~=3.14"
|
||||
@@ -38,112 +38,112 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "basedpyright"
|
||||
version = "1.39.9"
|
||||
requires_python = ">=3.8"
|
||||
version = "1.40.2"
|
||||
requires_python = ">=3.10"
|
||||
summary = "static type checking for Python (but based)"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"nodejs-wheel-binaries>=20.13.1",
|
||||
]
|
||||
files = [
|
||||
{file = "basedpyright-1.39.9-py3-none-any.whl", hash = "sha256:6b0837b9eba972c71895167ab9b127e6afdbc17abc92312e3f8d15ca82a5611c"},
|
||||
{file = "basedpyright-1.39.9.tar.gz", hash = "sha256:32cbea5fc8273e89df3db20daea56cb7286e419ccdfdc479c64759d2dc071901"},
|
||||
{file = "basedpyright-1.40.2-py3-none-any.whl", hash = "sha256:3a53aeb93a86675ded0e12bc27bd8476c1742356de07ad8cab67bff18110c690"},
|
||||
{file = "basedpyright-1.40.2.tar.gz", hash = "sha256:d4db36d095230379acc66c7e5fba7e4b50ca902f6e803d696782dd031cb8d7d7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.1"
|
||||
version = "3.5.2"
|
||||
requires_python = ">=3.7"
|
||||
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00"},
|
||||
{file = "charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb"},
|
||||
{file = "charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959"},
|
||||
{file = "charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e"},
|
||||
{file = "charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6"},
|
||||
{file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc"},
|
||||
{file = "charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21"},
|
||||
{file = "charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036"},
|
||||
{file = "charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e"},
|
||||
{file = "charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685"},
|
||||
{file = "charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -323,33 +323,58 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "msgspec"
|
||||
version = "0.21.1"
|
||||
version = "0.22.0"
|
||||
requires_python = ">=3.10"
|
||||
summary = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML."
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63"},
|
||||
{file = "msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90"},
|
||||
{file = "msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:221cbcbfa4478152b91d37dcfd4830e2be92773e8139e883f43773450ebacef8"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dd9568695911055440d2bb7099ed9098fc181d335daa772d0eb3fe8f31ba4efb"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f039ef5207b847f075a0a43020ee6140cd47505f890e47e157f2deb485c2dc96"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4f7e09cceac7dbf4c0761b8ae7df51c55b5df5e9af7aff2c895aac1ebea015"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:614e2c827e0a3f934f3cf0cf4ba65210df8132b75a69a8a1f51bb3b2caf0ac5a"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3689b9dfcc663358ef23ba4299d7460f01108515b041a7d30d05908ac9c32f"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2f950239ff1fc7322c6f9634807310265149cb168270d3ddcdda5b6ada13a28"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c789b5ccd07c0a3c09767108ee06e089b2875f2309a4569c2648f30a8d31dfa"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a66b1766311e42371e509c996c3933b161c7ae0eabdf361af5316dec197e1022"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:749899563d26b211379f142b8ffd7e2d7da149a51717798f0ce994dce50324f0"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:10d0d1d464960d99a949f7ca01ef8928e51c472433a5f5ab74b2d695fb830652"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e79725246291516a7359caad5fb743ddc0ec66ed40d2381fb846325b5031504e"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:38f7022fbe91954b31afe3888a0af1b652e0f370fafdeb1d425f4a814d789c9f"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6d3ca19a8ff28d0a67a1824e2bff7ec649ec795c80a265f20ade4caa63080de"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8b98ae215a102cbf6635f7df45f5c4af12f77fad1f7b71b9808fcf868a5735d"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0aa0cc3f18c35bab79bd7b87fde95d6274a9deddeebd1ea541f8066a5073165"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c8e84789918fbc15a503b92a829115ddd7567ecd3e4778bd418c56abbb86c11"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3ca7d4cd69fbb66bd2da6211d3e79d40542d196c16c6d99bf838f76767ad35be"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:28f53f3604dd3e70225f7563c831628dbb03299b428f8e62aadb4b628e386874"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7293dee54de040cfa225c22151cc3d72f17cd674b5ebcb52f38fb9f5701592e6"},
|
||||
{file = "msgspec-0.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c3c510aba9015c085e514b75a9b3f1ed7c4591ae5e379655821b8bba51f30cc7"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:263e110955ed76fe0af2d79f819903b50a70dc0e7a752eb7aabe79d2e0a084fb"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c6f06576eced70462179a4b4638e84cf69fdbba37f44d13a64a21739c131a830"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d67582478b0eaabb899f2fb255c878ee7de57dff80eb73ab24f1865524ec441"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:71cbbdb39631064e2f2f9e9ac2b1b69931d72276eb5f9da4ed025726296bdbb6"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0a5c25516e2034b2db7767081759ff8996e214def9c43b3055f61e1be1caad"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a1dab6a99c759d1391ab2993388c1892746a697254f4b5dc6c059ca6e3bfbc8b"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a52eba5c9528fd181fcec39d22b67aaa1dccc6cfe8e24d3f5d41130e6d04289d"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1e547966017265c0d23342bcf2e027305dde40ea042d16694a9b96b4f696a052"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-win_amd64.whl", hash = "sha256:0067057df265795f742658b15dbe53f3b6f21d19dcfa53676db11088cfa41e0a"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315-win_arm64.whl", hash = "sha256:05dbc8268e50c9232ec72b9af1c7b13049aade4d1197764e38c427048706e046"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:b3113ebcceeb7693a915183c73d92c10bf5c62851dd187cab43bd025fb587419"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dfadea8bdcfafc614bd031de55a8ede22b43445cfff6d8b77cc0c07d3edc8a8"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7a738826936c72348c613061d260446f13c82b6fd7d5d7705b6911ab8dca2f3"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2ddea9d78d09460f06c26a7a508adcd049761c3208776162b8eb79b8a032cff"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884c28c80b0a511595b29a9b04a3a230c3797369e4a033e6d5c6d9b5427f8e09"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f7a923bcde480065c8e25967464cfb2a687ee67000bb43157e2d57e40eca7305"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:65eea14bc65ccfeb8f3af62cb204841871e2961f002d7fa87dbe0f79dacf1c1c"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0666a1520cab86796612e794e71107e0fbf5e8ff3ddcdfcfff8f1d94b860d2f1"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-win_amd64.whl", hash = "sha256:885c6e0c89d6103648525fe62aa78d600054dedf7b3713d23b15d7ddb6d66a13"},
|
||||
{file = "msgspec-0.22.0-cp315-cp315t-win_arm64.whl", hash = "sha256:268594d0bae5510572599a6ab0364dd9de43c867d24a30856cd9f5edb63d8dc6"},
|
||||
{file = "msgspec-0.22.0.tar.gz", hash = "sha256:0a13624a4969159fe35d8c2a3d377b2b61bbd8585e327440d5e52725affcce38"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "niquests"
|
||||
version = "3.21.1"
|
||||
version = "3.21.2"
|
||||
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"]
|
||||
@@ -359,8 +384,8 @@ dependencies = [
|
||||
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
|
||||
]
|
||||
files = [
|
||||
{file = "niquests-3.21.1-py3-none-any.whl", hash = "sha256:ac1ff8061cb480cdaad07f08b95cf21ee0a68ef21fbc2b6ddf5afff8e0d0072e"},
|
||||
{file = "niquests-3.21.1.tar.gz", hash = "sha256:8ba6ee8712570ae41dfc59ac2ac7e509dcde660c6cc47078b43be423a2f99e64"},
|
||||
{file = "niquests-3.21.2-py3-none-any.whl", hash = "sha256:73e52614a712ee2f4d410bdd29789ca6a4ed031f8961410156e395d42b091208"},
|
||||
{file = "niquests-3.21.2.tar.gz", hash = "sha256:586d6b9475c9018a42f19f1e4688077b8158101cbf40673c63d2c1a096cc7665"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -397,13 +422,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.11.12"
|
||||
version = "4.12.3"
|
||||
requires_python = ">=3.10"
|
||||
summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
groups = ["default"]
|
||||
files = [
|
||||
{file = "platformdirs-4.11.12-py3-none-any.whl", hash = "sha256:b1ba966ac76153d9985a0787ffab951e7c18416189873d4e4ddb2c8a04a0a254"},
|
||||
{file = "platformdirs-4.11.12.tar.gz", hash = "sha256:e8dc1cb58f1153fd7f61db1374317770baababec2480b37b8f01c6cc25b45267"},
|
||||
{file = "platformdirs-4.12.3-py3-none-any.whl", hash = "sha256:080f3b39423b5abfca9a23d84c4e9795f54d395cd8459867a8ded44084fcd5f8"},
|
||||
{file = "platformdirs-4.12.3.tar.gz", hash = "sha256:427fc0bb321ae0c5b037fa03238ca74820437be162e78b4848c4d4055b9b766c"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -481,47 +506,47 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "qh3"
|
||||
version = "2.0.3"
|
||||
version = "2.0.4"
|
||||
requires_python = ">=3.7"
|
||||
summary = "A lightweight and fast implementation of QUIC and HTTP/3"
|
||||
groups = ["default"]
|
||||
marker = "(platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\""
|
||||
files = [
|
||||
{file = "qh3-2.0.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:db98ea31b7486cac5dffc76c15b34c08208b219379aa9d274df21b1d47c7dbcb"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb0a8f54275e0ecb722c0a6cebeff73771b31da0dbfaed8e4b72efcbe98e2ab"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e466a285b8f598ea06a296910a817571f132bb3ff3f5a26cdc905ffe6d9d743c"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a266a68f27a86d6d49978e1c6b8e9e5dd514e018a042dafca48cbeb50a29d0d3"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2646d05d20e7171d928d9b478097743d0d2a5b3893f8caddd7984b2825d90daa"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ccac4672539eb2897c3beaa73b626460040adf94c30edbd420db3988bd08e0f1"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0e57b96af05653ddcc4797fe761918d5d0a25ed90a9176260333f48e7b88f72"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73845b61987f44c5d55f9606a38b4078cac7e816dadeeaceb35a49ff741d4c8e"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:4f05f1b3ac250213db0da6895aeb944ca3cb63cd1a41296f1dddafd8d78a1722"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3be9e2cfc447807c4195c12d9590052ee45cc17ed92d99f44d05a49d657bf8d4"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:bb83310f908a9d00b781fe95f7acda51025d0cea3a985929e84b0d46333709a2"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:991ff127a8b0aef9ae07a4155f595158fd005d9cc522d204ec31a5a5f197517c"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:efbcd87e7c45f51690f6e526ed4956c57e428eb4c5684878987d43ffa14a93b0"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:86f2256aa78604d6733ff12a225d6780df18a9b171e72d45dd30ee62d5ab0568"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:91569e8ee979c84a1972916cc9b3283760dda111fc8f67cce45ccdb4f14b67c5"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:eb0333f67bee13a3b5f08e91ece87fd0f5e5448e955efb11d7bd2bfacf21bfb5"},
|
||||
{file = "qh3-2.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:8c9f6b22d073948b3042ec0b50dab5581865a30d41517ba6aad8dbc1a4b39a6b"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e96296b00eb23ff828d7789070b3d22c7139efbc6bc62fed366c31bad622d174"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a09e33277ded5f94f9b73b0c8c2f5b3e044f3c41ed5c830ab61e72962e22d09"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8bbbc57b1cff5acebf736965c072f35023f4d0e5975b3d8cda7b7e07250af1b"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06b8ee7434cd0ecab6b916abc63a46f4ad8b3ebbc284da405aeb650882826e91"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec715af40af199c92eedbd620bbff8ddc514e19f2bbd660e65953752c364105f"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bcbdc6a574f83f957e6bf430cda8b98a512870164563b7ef1fdd553420f032a"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5c9ba43b3f68f9ade9553ff23d89455596b9c5ba8179a103f75c8771d355535"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51480d90b4ac7e81248fd020a726d4089cf8b881a4b6cbdc3c04c329831a3fc6"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:235161f4cbd791e18ea865d10fdd52d0ddbcbb807a1849c98360c4e570c06844"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7abb8ae6c8ae6b49827b26eaffeac0892893450d2022818e1db3ff34d26a835e"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:57f43869ef306877167277c48c1fa7c446fc73fc9355fa1ccae5094507b63d9e"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:192021606e283a59b47bf1e1cb719087c77869976aea76f26d589a1df8aa0dd5"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:f33ca1ac278a8d8ff115bcde76fc4555fa1dbfa147e83dad2ebcba6bc663e6d5"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:74814e3dd91011235905856701037ead68f7a8be641f469fa5e5cdabf7d03a28"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-win32.whl", hash = "sha256:20ceb5482ef6f352d9c7351fbde69f94f49d07f068ce9aa4e88ea76fca7eddda"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:94ba6ddb786742ff93904db09cb4b6fbcaf7de115161c05918024cd52b2c8a61"},
|
||||
{file = "qh3-2.0.3-cp37-abi3-win_arm64.whl", hash = "sha256:0b13789c68983a994e8c12e3ff0fbc02f07c0aaaeec5670b57802f5d3d555e81"},
|
||||
{file = "qh3-2.0.3.tar.gz", hash = "sha256:546ab2d3193e37e98dfefe5834484e07d0bf531708f21e1a4e227a51510b648f"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a64bf88e94862d54a0406db92b4c51e86775e2fa5223618f92431f7ea0e668a1"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d9d22cdf5a643f3f3236b3e25462d33083f88d200425afd09958cc0d2c5ca54"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f9d726d83942a2cb20ee127e995a0249dbea3ffe2b3772eb113acfdfd3874a1"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:968a94dcdc85219794d44fdce7b15ce6a90ca295f2574881b623ebe06850311c"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bcae368dc6b5449397f81208493361a5197df1f955d4883b164294915f83638b"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f201b834d96759a241171a4cdc6ab7442a6c52260d1dcfb1879707b430c6d7"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f47b1f2c5fe8d70472a6134f374b6022ad6f709d6be56282ad4024d28905829"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edc81973c3823533d75da5570e89214d87aded6205ac76cb9ae3bb9f25b5ffe6"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1964c6471d5e4c074a002bf6a95c1797576165c000d74448260426e10209bed0"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:690cf4bcd8c7baf075187d6197ab31cc933d2aa951311dc65729cb01dccc92dc"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:8449e99c275b04c866f13f24e6d656d609fe8875fedf5896201a93a60bbe1412"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:db25d8132c966ccebc294b8aeb197d2f48d14f772e675ff8282ab958009b801b"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:c03566def5374925a6270bbc1dafc8c8889b3ff3ecd14396979ea07fec9def26"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:19cd7751da3f4d581ea6f5f44510a2b30a19495daba4e632c4e96d8375856694"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-win32.whl", hash = "sha256:c6414837e375fd06f4664b276fcc3e76b17fa2431f650f52f0e41c48489d7e81"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1923830f3e5e236bcf024ea317625346e5a3e1b08e7852e83293c1e4b3b8d7db"},
|
||||
{file = "qh3-2.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9d505d790ab10e378cf4e5c2d6447cb8b602d9cc36a4113160f9ece7bba9abc6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec087930c9b43d1e32c5c4cdc8d550fcb72cbfcc957c1d922859ce11b1e55663"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:406577d5fc72bed8d12b6397099b4e164948171dd0a16be8e768b31f1ef155ae"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e94ba4e929e0e54f615c4e28493980af5d82a6d928c644a2da26983df73e110"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03015a711f8b1ab24825d6ac6d2af1874d4157ac886d236bc19f7a21e69377b6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fda466d648cebffccca1a68d9716b31d41eb09b284ecf17705b4a04991dbb881"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06e8c7ad92fcb482de360af5ae00d8e4559e969d1559a5a88b68989c6bac22c4"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c0907ab53bd1fb1cca0a309cf26df42c9862e43e40c79d0e5ac3d2b6b747689"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af34fad8554560fa38304f61708176379d5efb192da2ece5fb25dec23c304b36"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:b6760be9bc0401bf7e6c1e889181587619529f34ffcf3f8a8de5f074815dc2ab"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a29a0c05b7986ae0400a4229ca7176805cd44774675663b4ab0d4b6ebef7655c"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:3dcedaeaf791114a050762b097b18839f6cc491dbe83873137b080f7b6dd1411"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:8a8fbc4f30683a47dfb2b9eebc95e5f8b76c3a1eb818b0dd3354aad54c2d99f6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:d777f66879bda1e2b73caa2ca624045362d3aab8b474d2db868d0f284822e24c"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1dad9bae44479f682cad2e0c0ea8cbd384ce42cd2921f1f1b4c03e1299f6acbc"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-win32.whl", hash = "sha256:45d57837ef9990c58c1776a12d551e7c88f2592a9885b6e781d30e18062b57c6"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:53f7180a74db23f5469a610a6bcdf2b29a4ed45e067a40276c83b1cead3816df"},
|
||||
{file = "qh3-2.0.4-cp37-abi3-win_arm64.whl", hash = "sha256:79e93801a8f33e651a7751bde3e0e120b565a93d18d146bf1fc8127d1669494f"},
|
||||
{file = "qh3-2.0.4.tar.gz", hash = "sha256:72b174c9dabfccdddf050dabc3c1b49972ed7a7564896d3db558e57376187f75"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -541,59 +566,83 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.22"
|
||||
version = "0.16.10"
|
||||
requires_python = ">=3.7"
|
||||
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8"},
|
||||
{file = "ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697"},
|
||||
{file = "ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f"},
|
||||
{file = "ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576"},
|
||||
{file = "ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178"},
|
||||
{file = "ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224"},
|
||||
{file = "ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a"},
|
||||
{file = "ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e"},
|
||||
{file = "ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb"},
|
||||
{file = "ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74"},
|
||||
{file = "ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c"},
|
||||
{file = "ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296"},
|
||||
{file = "ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262"},
|
||||
{file = "ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64"},
|
||||
{file = "ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf"},
|
||||
{file = "ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde"},
|
||||
{file = "ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661"},
|
||||
{file = "ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809"},
|
||||
{file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"},
|
||||
{file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"},
|
||||
{file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"},
|
||||
{file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"},
|
||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"},
|
||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"},
|
||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"},
|
||||
{file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"},
|
||||
{file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"},
|
||||
{file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"},
|
||||
{file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"},
|
||||
{file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.54"
|
||||
requires_python = ">=3.7"
|
||||
version = "2.1.3"
|
||||
requires_python = ">=3.11"
|
||||
summary = "Database Abstraction Library"
|
||||
groups = ["default"]
|
||||
dependencies = [
|
||||
"greenlet>=1; platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"",
|
||||
"importlib-metadata; python_version < \"3.8\"",
|
||||
"typing-extensions>=4.6.0",
|
||||
]
|
||||
files = [
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51"},
|
||||
{file = "sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970"},
|
||||
{file = "sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2"},
|
||||
{file = "sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:660c6e16d254568874d4b77fd593aa5426f55cf40d0acc8b5364a5e61cc1044d"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7277ba31ffb636666ed207d2aaf74de3cd2ee78436da7552bd4eeadf1f67740a"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b728c406b1b202e8998e8f9adacb56773bfeea67da74252ad3025bcb8f5863f1"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:85623c57c3696dcbc4fc909bbf335a2f0c27ae36f762d83810067fb49abc040e"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0de6dc326cc99ac31206359e9fc2c13345f667edeb6ef556585fedac4ff698"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:65e002b794efa1bacbbde9fa973e5340d6c3c29b53e592b863a281802829319d"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65e4813c78eaa679c9369b289e67fdba924db6be14067700c5ccbda37953af9a"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-win32.whl", hash = "sha256:726987e7f8500614016be056ba5c9715b760ab465295741e2087b29753d72632"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:0560458a775917bcd7a47eccf4f9e0b135fd9b72d7b819cb1bd60581de946f9a"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:63c9289ed81991ba4d500611ce201531711430f5d23d7ae9a61a8842639b85ea"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:08b2f345db8b0742e7814c49f9a210d5f5c8fb9d80d12c8a63bba762fa277477"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:493b632f426579c4b0e97a5610f76c8c3d401cede442d462bf700b537035c91e"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e06933580f4faa5a7b1d2c9ad04f365fe01c40465eb982e38fda8758c1e95c"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdcee9536cb3281b8810787ba651f4e5f22bd687b177de89fba2155b055b07b0"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d2cb28779dbf54067655030d7ab3a6726c7957c56bde5ff6745ed86b5cebff41"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:01a90cac3f8d93fcbda0df119ef56c4d58aad5601f6b7ee5a38bfabbd5d119b8"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:edbc28049ab8da07387ea2b85ceee747b36b25444ffa4aaba9f6e1461d5b2634"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-win32.whl", hash = "sha256:724f587ced076d89291032b35476d6bdb401cce1dd680c1e3418c01d9bd55ad4"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:935085a5b8849d324c22bc3cc09fd07b3669cc22ba5bdd3ec208829e535cdef9"},
|
||||
{file = "sqlalchemy-2.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2ebfff82cece947fea1ed6337d4f0613b9799f6db621c9729790fdd41edfb5ea"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:47a052dcd935a62326216bc5f0ed10ad941c77c3acc9200385e0c03c4f31d5a9"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e5916b43a9a8f346026f663583f203d0c43febad504ff2fed504dec6e078f20"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af78a85af63114e2650f019ddf8fffb871e659e92bffb7d0bce5753f706caf"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d218698c4f05728a941d6ad7c769b6491f03058e102cf811b7e4900f78fffa07"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e6ccf111bc3f57530935f5541c6c1da50ac9713c418378840683532a45dd2d"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9a3ce1ff88dfb9f9f4305da07f4fab87720b609e264d134e47ae84a52df130bd"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:77a365f7cb81bbd738c948b64e0b683f8f1b68efe51ca2815e8ad4baf6287ed9"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-win32.whl", hash = "sha256:83757320391b96cde715af0772675941d3e345da9ced3696c030f7f55ffa8eab"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:f87533295b84c9e19ca2a72dc26a76e0192f0a2424f8fd575a82e75e7d58f9e1"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:3e46a27b0c74af74aa6c08c983ca51db88d22dd86c316d72a634e989b7caef58"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:06ef75e3e0c72d774b41f659bfc4f63d5e207d6c8418abe2b80843a4a25e2fa6"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70e05ae07dd9be29d4723bd0c880df622387eb51efb46a7b8efd49295248ffd7"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10fdff11782842696172489bd4861ae63c212e62ee7c4fc61fdda90961abd1f3"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:edd3e6690f350c89c616e78e9b58833c3d8925607ac5faf3530f38f053f58e4d"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c165df26eca3d2612f60ea91af02648cb1bd492eadfa64dba15c9a3a0fb09c"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:70c073d28d043766eb1725010792f302f59b14636ffef478793045471efe92dd"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:bf5f63229512f14e7bffc9a0041386318f592e76bbeaf9b750b39056094d91f3"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-win32.whl", hash = "sha256:697942762c40e31eb0fa39bf0e58e1ac1b5b1ff9022da62b74f7cdc53096a482"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:a9c33c0d081a9d2c94229b9ae1834cb2ef81dd81253df4763e32111287b64c31"},
|
||||
{file = "sqlalchemy-2.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:c36ff11196dacf50312a2cc51a798bb41611331c174871f065be651a838a5361"},
|
||||
{file = "sqlalchemy-2.1.3-py3-none-any.whl", hash = "sha256:6f0cf4debd86cb5623a50310784bd005c215761ca7f618d0d4f46193d033675a"},
|
||||
{file = "sqlalchemy-2.1.3.tar.gz", hash = "sha256:ade5281df06038c6394f532590d592d3381ee5d11b9e0006a2570aef41145118"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -620,7 +669,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3-future"
|
||||
version = "2.25.900"
|
||||
version = "2.25.902"
|
||||
requires_python = ">=3.7"
|
||||
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
|
||||
groups = ["default"]
|
||||
@@ -630,8 +679,8 @@ dependencies = [
|
||||
"qh3<3.0.0,>=1.5.4; (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and platform_python_implementation == \"PyPy\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and python_version < \"3.12\" or (platform_system == \"Darwin\" or platform_system == \"Windows\" or platform_system == \"Linux\") and python_full_version > \"3.7.10\" and (platform_machine == \"x86_64\" or platform_machine == \"s390x\" or platform_machine == \"armv7l\" or platform_machine == \"ppc64le\" or platform_machine == \"ppc64\" or platform_machine == \"AMD64\" or platform_machine == \"aarch64\" or platform_machine == \"arm64\" or platform_machine == \"ARM64\" or platform_machine == \"x86\" or platform_machine == \"i686\" or platform_machine == \"riscv64\" or platform_machine == \"riscv64gc\") and platform_python_implementation == \"CPython\"",
|
||||
]
|
||||
files = [
|
||||
{file = "urllib3_future-2.25.900-py3-none-any.whl", hash = "sha256:1d4809b9714aef7f35338b7d3a388b0280aefa41d2edb78ba48de91309d014c8"},
|
||||
{file = "urllib3_future-2.25.900.tar.gz", hash = "sha256:0583bd313d223cb0d92367fe9bf07e7c8d926eebb0bdf6960670102e7ad4b6c1"},
|
||||
{file = "urllib3_future-2.25.902-py3-none-any.whl", hash = "sha256:88e6f3c19f553931e78f84f011a34220f827c971e6cf06b3a075eaa788c2bf7d"},
|
||||
{file = "urllib3_future-2.25.902.tar.gz", hash = "sha256:5c443668b9f87bce38bad1fdec7d7950c47e9c5b7328b1fd029a429741f3be70"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -23,7 +23,7 @@ repos = [
|
||||
types_or = ["python", "pyi"],
|
||||
args = [],
|
||||
require_serial = true,
|
||||
additional_dependencies = ["ruff==0.15.22"],
|
||||
additional_dependencies = ["ruff==0.16.10"],
|
||||
},
|
||||
{
|
||||
id = "ruff-format",
|
||||
@@ -32,7 +32,7 @@ repos = [
|
||||
language = "python",
|
||||
types_or = ["python", "pyi"],
|
||||
require_serial = true,
|
||||
additional_dependencies = ["ruff==0.15.22"],
|
||||
additional_dependencies = ["ruff==0.16.10"],
|
||||
},
|
||||
{
|
||||
id = "basedpyright",
|
||||
@@ -41,7 +41,7 @@ repos = [
|
||||
language = "python",
|
||||
pass_filenames = false,
|
||||
require_serial = true,
|
||||
additional_dependencies = ["basedpyright==1.39.9"],
|
||||
additional_dependencies = ["basedpyright==1.40.2"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
@@ -27,8 +27,8 @@ plyngent = "plyngent.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright==1.39.9",
|
||||
"ruff==0.15.22",
|
||||
"basedpyright==1.40.2",
|
||||
"ruff==0.16.10",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-asyncio>=1.4.0",
|
||||
]
|
||||
@@ -136,7 +136,11 @@ ignore = [
|
||||
"COM812", # missing-trailing-comma
|
||||
"PLC0415", # import-outside-top-level
|
||||
"TID252",
|
||||
# Same axis as C901 (conditional branches per function) but a looser default,
|
||||
# and C901 flags every function it does — keep the one gate (see AGENTS.md).
|
||||
"PLR0912", # too-many-branches
|
||||
"PLR0913", # too-many-arguments (common on constructors/composed functions)
|
||||
"PLR0917", # too-many-positional-arguments (same family as PLR0913 above)
|
||||
]
|
||||
allowed-confusables = [
|
||||
",",
|
||||
|
||||
+100
-8
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import msgspec
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
@@ -37,7 +39,7 @@ from .usage import TokenUsage
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
from plyngent.lmproto.openai_compatible.model import AnyAssistantToolCall, AnyChatMessage
|
||||
from plyngent.memory import MemoryStore
|
||||
|
||||
from .events import AgentEvent
|
||||
@@ -206,6 +208,78 @@ def committed_prefix_end(messages: Sequence[AnyChatMessage], user_index: int) ->
|
||||
return end
|
||||
|
||||
|
||||
def rollback_tail(messages: Sequence[AnyChatMessage], start: int) -> list[AnyChatMessage]:
|
||||
"""Rows to keep from *start* when a failed turn is rolled back.
|
||||
|
||||
The trailing tool batch is committed only once every call has its result
|
||||
(:func:`committed_prefix_end`), so a batch cut short — cancelled before the
|
||||
last result landed, or ids that do not pair up exactly — would be dropped
|
||||
whole and the retry would run calls that already had external effects. Keep
|
||||
the executed ones: their assistant message stays as the carrier for the
|
||||
pairing (API messages must answer every ``tool_calls`` id), without the text
|
||||
of the unfinished turn, and the calls that never reported a result are
|
||||
absent so the retry re-issues them instead of assuming they ran.
|
||||
|
||||
Developer rows (host notices, directive checkpoints) are already durable, so
|
||||
they stay in place; unfinished assistant text and stray rows go.
|
||||
"""
|
||||
body = list(messages[start:])
|
||||
kept: list[AnyChatMessage] = []
|
||||
index = 0
|
||||
while index < len(body):
|
||||
message = body[index]
|
||||
if isinstance(message, DeveloperChatMessage):
|
||||
kept.append(message)
|
||||
index += 1
|
||||
continue
|
||||
if isinstance(message, AssistantChatMessage) and message.tool_calls is not UNSET and message.tool_calls:
|
||||
results: list[ToolChatMessage] = []
|
||||
follow = index + 1
|
||||
while follow < len(body):
|
||||
following = body[follow]
|
||||
if not isinstance(following, ToolChatMessage):
|
||||
break
|
||||
results.append(following)
|
||||
follow += 1
|
||||
kept.extend(_executed_tool_pair(message, message.tool_calls, results))
|
||||
index = follow
|
||||
continue
|
||||
index += 1
|
||||
return kept
|
||||
|
||||
|
||||
def _executed_tool_pair(
|
||||
assistant: AssistantChatMessage,
|
||||
calls: Sequence[AnyAssistantToolCall],
|
||||
results: Sequence[ToolChatMessage],
|
||||
) -> list[AnyChatMessage]:
|
||||
"""Assistant call record plus the results of the calls that produced one.
|
||||
|
||||
Pairs by call id (first result per id), so the history keeps the API's
|
||||
one-result-per-call shape even when a model repeats an id. Text goes with
|
||||
the rest of the unfinished turn; the reasoning stays, because thinking mode
|
||||
refuses a tool call whose reasoning never came back (see
|
||||
``responses_bridge`` / the DeepSeek chat client, which can only send an
|
||||
empty part once the real chain-of-thought is gone).
|
||||
"""
|
||||
by_id: dict[str, ToolChatMessage] = {}
|
||||
for result in results:
|
||||
_ = by_id.setdefault(result.tool_call_id, result)
|
||||
executed: list[AnyAssistantToolCall] = []
|
||||
paired: list[AnyChatMessage] = []
|
||||
seen: set[str] = set()
|
||||
for call in calls:
|
||||
if call.id in seen or call.id not in by_id:
|
||||
continue
|
||||
seen.add(call.id)
|
||||
executed.append(call)
|
||||
paired.append(by_id[call.id])
|
||||
if not executed:
|
||||
return []
|
||||
record = msgspec.structs.replace(assistant, content=UNSET, tool_calls=executed)
|
||||
return [record, *paired]
|
||||
|
||||
|
||||
class ChatAgent:
|
||||
"""Thin wrapper: chat client + optional tools + optional memory bind."""
|
||||
|
||||
@@ -216,6 +290,8 @@ class ChatAgent:
|
||||
session_id: int | None
|
||||
max_rounds: int
|
||||
temperature: float | None
|
||||
# Resolved thinking strength ("" = provider default); see config.reasoning.
|
||||
reasoning_effort: str
|
||||
on_limit: LimitContinueHook | None
|
||||
stream: bool
|
||||
system_prompt: str | None
|
||||
@@ -247,6 +323,7 @@ class ChatAgent:
|
||||
session_id: int | None = None,
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "",
|
||||
messages: Sequence[AnyChatMessage] | None = None,
|
||||
on_limit: LimitContinueHook | None = None,
|
||||
stream: bool = True,
|
||||
@@ -269,6 +346,7 @@ class ChatAgent:
|
||||
self.session_id = session_id
|
||||
self.max_rounds = max_rounds
|
||||
self.temperature = temperature
|
||||
self.reasoning_effort = reasoning_effort
|
||||
self.on_limit = on_limit
|
||||
self.stream = stream
|
||||
self.system_prompt = system_prompt
|
||||
@@ -475,10 +553,20 @@ class ChatAgent:
|
||||
msg = "nothing to retry"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def _rollback_uncommitted(self, user_index: int) -> None:
|
||||
"""Drop incomplete suffix; keep committed tool rounds after the user."""
|
||||
async def _rollback_uncommitted(self, user_index: int) -> None:
|
||||
"""Drop the unfinished suffix; keep committed rounds and executed tools.
|
||||
|
||||
Rows before the checkpoint cursor are already stored, so only the kept
|
||||
suffix is written: a restart has to continue from the same tool results
|
||||
the model is about to see, or the retry re-runs their effects.
|
||||
"""
|
||||
end = committed_prefix_end(self.messages, user_index)
|
||||
del self.messages[end:]
|
||||
self.messages[end:] = rollback_tail(self.messages, end)
|
||||
if len(self.messages) > self._persist_from:
|
||||
# A store error must not mask the failure being rolled back; the
|
||||
# rows stay in memory, and retry() writes what is still unstored.
|
||||
with contextlib.suppress(Exception):
|
||||
await self._persist_range(self._persist_from, len(self.messages))
|
||||
|
||||
def _developer_tail_end(self, start: int) -> int:
|
||||
"""Extend *start* through trailing developer checkpoint messages."""
|
||||
@@ -492,7 +580,8 @@ class ChatAgent:
|
||||
|
||||
After each completed tool batch, commits assistant+tool messages to the
|
||||
DB and keeps them on failure so :meth:`retry` continues without redoing
|
||||
side-effecting tools. Unfinished assistant/stream suffix is rolled back.
|
||||
side-effecting tools. Without such a batch, only the unfinished text is
|
||||
dropped: calls that did report a result survive (:func:`rollback_tail`).
|
||||
"""
|
||||
user_index = self._user_index(user_msg)
|
||||
if self.todo_stack is not None:
|
||||
@@ -515,6 +604,7 @@ class ChatAgent:
|
||||
tools=self.tools,
|
||||
max_rounds=self.max_rounds,
|
||||
temperature=self.temperature,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
on_limit=self.on_limit,
|
||||
stream=self.stream,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
@@ -550,7 +640,7 @@ class ChatAgent:
|
||||
completed = True
|
||||
except BaseException:
|
||||
if not completed:
|
||||
self._rollback_uncommitted(user_index)
|
||||
await self._rollback_uncommitted(user_index)
|
||||
raise
|
||||
|
||||
self.last_turn_usage = turn_usage
|
||||
@@ -658,6 +748,7 @@ class ChatAgent:
|
||||
session_id=None,
|
||||
max_rounds=rounds,
|
||||
temperature=self.temperature,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
on_limit=self.on_limit,
|
||||
stream=self.stream,
|
||||
system_prompt=self.system_prompt if system_prompt is None else system_prompt,
|
||||
@@ -687,7 +778,8 @@ class ChatAgent:
|
||||
user_msg = self.messages[user_index]
|
||||
assert isinstance(user_msg, UserChatMessage)
|
||||
self._ensure_system_prompt()
|
||||
# Already-committed messages stay; only new rounds are persisted.
|
||||
self._persist_from = len(self.messages)
|
||||
# Already-committed rows stay; write any the failed rollback could not.
|
||||
if self._persist_from < len(self.messages):
|
||||
await self._persist_range(self._persist_from, len(self.messages))
|
||||
async for event in self._run_from_user_message(user_msg):
|
||||
yield event
|
||||
|
||||
+41
-14
@@ -57,7 +57,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from plyngent.lmproto.anthropic.client import AnthropicClient
|
||||
from plyngent.lmproto.openai.client import OpenAIClient
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem, ReasoningEffort
|
||||
|
||||
from .todo_nag import TodoNagStrategy
|
||||
from .todo_stack import TodoStack
|
||||
@@ -124,10 +124,14 @@ async def _execute_tool_calls(
|
||||
else:
|
||||
results = [await _run_one_tool(tools, call, max_result_chars=max_result_chars) for call in tool_calls]
|
||||
|
||||
# Record every result before yielding any: a caller that goes away mid-batch
|
||||
# (cancel, display error) must not lose work that already ran, or the retry
|
||||
# runs that tool a second time.
|
||||
for tool_msg, _err in results:
|
||||
messages.append(tool_msg)
|
||||
for tool_msg, err in results:
|
||||
if err is not None:
|
||||
yield err
|
||||
messages.append(tool_msg)
|
||||
yield ToolResultEvent(message=tool_msg)
|
||||
|
||||
|
||||
@@ -217,6 +221,7 @@ async def _dispatch_chat_completions(
|
||||
return await dispatch_messages(
|
||||
cast("AnthropicClient", client),
|
||||
param,
|
||||
provider_tools=provider_tools,
|
||||
stream=stream,
|
||||
)
|
||||
msg = f"client kind {kind!r} is not supported by the agent loop"
|
||||
@@ -255,16 +260,27 @@ async def _non_stream_round(
|
||||
)
|
||||
|
||||
|
||||
def _append_reasoning_delta(
|
||||
def _take_reasoning_delta(
|
||||
reasoning_parts: list[str],
|
||||
delta: DeltaMessage,
|
||||
) -> list[str]:
|
||||
"""Accumulate a reasoning delta; a ``full`` (``.done``) event replaces fragments."""
|
||||
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
|
||||
if delta.reasoning_full is True:
|
||||
return [delta.reasoning_content]
|
||||
return [*reasoning_parts, delta.reasoning_content]
|
||||
return reasoning_parts
|
||||
) -> tuple[list[str], list[ReasoningDeltaEvent]]:
|
||||
"""Fold a reasoning delta into the chain of thought; say what to stream.
|
||||
|
||||
Fragments append to *reasoning_parts*; a ``full`` delta replaces them, since
|
||||
DeepSeek's ``response.reasoning_text.done`` carries the whole chain of thought
|
||||
its fragments spelled out. The display already printed those fragments, so a
|
||||
full delta streams only a tail they did not cover — emitting it verbatim
|
||||
printed the CoT twice — and is dropped when it does not continue them at all.
|
||||
A round that streamed nothing streams the full text once.
|
||||
"""
|
||||
content = delta.reasoning_content
|
||||
if not isinstance(content, str) or not content:
|
||||
return reasoning_parts, []
|
||||
if delta.reasoning_full is not True:
|
||||
return [*reasoning_parts, content], [ReasoningDeltaEvent(content=content)]
|
||||
streamed = "".join(reasoning_parts)
|
||||
fresh = content.removeprefix(streamed) if content.startswith(streamed) else ""
|
||||
return [content], [ReasoningDeltaEvent(content=fresh)] if fresh else []
|
||||
|
||||
|
||||
async def _stream_round(
|
||||
@@ -307,9 +323,12 @@ async def _stream_round(
|
||||
finish_reason = fr
|
||||
saw_terminal = True
|
||||
delta = choice.delta
|
||||
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
|
||||
reasoning_parts = _append_reasoning_delta(reasoning_parts, delta)
|
||||
yield ReasoningDeltaEvent(content=delta.reasoning_content)
|
||||
# A delta without reasoning folds to nothing, so every chunk can be taken
|
||||
# here; the display sees only what it has not printed yet (see
|
||||
# :func:`_take_reasoning_delta`).
|
||||
reasoning_parts, reasoning_events = _take_reasoning_delta(reasoning_parts, delta)
|
||||
for reasoning_event in reasoning_events:
|
||||
yield reasoning_event
|
||||
if isinstance(delta.content, str) and delta.content:
|
||||
content_parts.append(delta.content)
|
||||
yield TextDeltaEvent(content=delta.content)
|
||||
@@ -406,7 +425,7 @@ async def _maybe_inject_directive_checkpoints(
|
||||
return new_band
|
||||
|
||||
|
||||
async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
|
||||
async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
client: AnyLLMClient,
|
||||
messages: list[AnyChatMessage],
|
||||
*,
|
||||
@@ -414,6 +433,7 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
|
||||
tools: ToolRegistry | None = None,
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "",
|
||||
on_limit: LimitContinueHook | None = None,
|
||||
stream: bool = True,
|
||||
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
||||
@@ -434,6 +454,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``.
|
||||
|
||||
*reasoning_effort* is the resolved thinking strength (``""`` = send nothing,
|
||||
the provider's own default); each API surface maps it onto its own wire field
|
||||
(see ``config.reasoning``).
|
||||
|
||||
*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.
|
||||
@@ -478,6 +502,9 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
|
||||
model=model,
|
||||
temperature=temperature if temperature is not None else UNSET,
|
||||
tools=list(tool_items) if tool_items is not None else UNSET,
|
||||
# "max" is DeepSeek-only; config.reasoning already clamped it away
|
||||
# for every other surface, so this cast does not hide a mistake.
|
||||
reasoning_effort=cast("ReasoningEffort", reasoning_effort) if reasoning_effort else UNSET,
|
||||
)
|
||||
|
||||
pre_len = len(messages)
|
||||
|
||||
@@ -18,7 +18,9 @@ from plyngent.lmproto.anthropic.model import (
|
||||
AnthropicMessageResponse,
|
||||
AnthropicMessagesParam,
|
||||
AnthropicResponseText,
|
||||
AnthropicResponseToolUse,
|
||||
AnthropicTextContent,
|
||||
AnthropicThinkingConfig,
|
||||
AnthropicToolChoice,
|
||||
AnthropicToolDefinition,
|
||||
AnthropicToolResultContent,
|
||||
@@ -68,11 +70,11 @@ def _encode_tool_arguments(obj: dict[str, Any]) -> str:
|
||||
|
||||
def tool_items_to_anthropic_tools(
|
||||
tools: Sequence[AnyToolItem] | None,
|
||||
) -> list[AnthropicToolDefinition]:
|
||||
) -> list[AnthropicToolDefinition | dict[str, Any]]:
|
||||
"""Map chat ``ToolFunctionItem`` list to Anthropic tool definitions."""
|
||||
if not tools:
|
||||
return []
|
||||
result: list[AnthropicToolDefinition] = []
|
||||
result: list[AnthropicToolDefinition | dict[str, Any]] = []
|
||||
for item in tools:
|
||||
if not isinstance(item, ToolFunctionItem):
|
||||
continue
|
||||
@@ -87,6 +89,35 @@ def tool_items_to_anthropic_tools(
|
||||
return result
|
||||
|
||||
|
||||
# The surface-neutral hosted-tool intent (``provider_tools`` default) as the
|
||||
# Anthropic server tool it means; DeepSeek's compat and Claude both define the
|
||||
# search tool this way, and both ignore an OpenAI-shaped ``web_search`` entry.
|
||||
_ANTHROPIC_WEB_SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20250305", "name": "web_search"}
|
||||
|
||||
|
||||
def provider_tools_to_anthropic_tools(
|
||||
provider_tools: Sequence[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Map hosted-tool dicts to Anthropic server tools (``[]`` when none).
|
||||
|
||||
Entries arrive in the OpenAI Responses shape the config defaults to, where a
|
||||
bare ``web_search`` states the intent only; the Anthropic surface wants its
|
||||
own definition, so that entry becomes
|
||||
``{"type": "web_search_20250305", "name": "web_search"}`` — the server tool
|
||||
Claude declares and DeepSeek's compat reads — keeping any other key (a
|
||||
``max_uses`` cap, say). Anything else passes through as it is.
|
||||
"""
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in provider_tools or ():
|
||||
entry = dict(item)
|
||||
if entry.get("type") == "web_search":
|
||||
extra = {key: value for key, value in entry.items() if key not in {"type", "name"}}
|
||||
entry = {**_ANTHROPIC_WEB_SEARCH_TOOL, **extra}
|
||||
if entry.get("type"):
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice | Unset:
|
||||
if param.tool_choice is UNSET:
|
||||
return UNSET
|
||||
@@ -189,7 +220,12 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
|
||||
|
||||
|
||||
def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> AssistantChatMessage:
|
||||
"""Map a completed Anthropic message to agent ``AssistantChatMessage``."""
|
||||
"""Map a completed Anthropic message to agent ``AssistantChatMessage``.
|
||||
|
||||
Server-side blocks (``server_tool_use`` / ``web_search_tool_result``) and
|
||||
``thinking`` are not part of the chat-shaped message: a search the model ran
|
||||
through its backend already shows up in the text it answered with.
|
||||
"""
|
||||
text_parts: list[str] = []
|
||||
tool_calls: list[AnyAssistantToolCall] = []
|
||||
for block in response.content:
|
||||
@@ -197,7 +233,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis
|
||||
if block.text:
|
||||
text_parts.append(block.text)
|
||||
continue
|
||||
# AnthropicResponseToolUse (remaining content arm)
|
||||
if not isinstance(block, AnthropicResponseToolUse):
|
||||
continue
|
||||
tool_calls.append(
|
||||
AssistantFunctionToolCall(
|
||||
id=block.id,
|
||||
@@ -273,16 +310,93 @@ def _max_tokens_from_param(param: ChatCompletionsParam) -> int:
|
||||
return 8192
|
||||
|
||||
|
||||
def chat_param_to_anthropic_param(param: ChatCompletionsParam) -> AnthropicMessagesParam:
|
||||
"""Build :class:`AnthropicMessagesParam` from a chat completions param."""
|
||||
# Effort level → Anthropic ``thinking`` budget (an effort has no wire form there).
|
||||
# Unknown levels fall back to "medium"; ``none`` is handled separately (disabled).
|
||||
_ANTHROPIC_THINKING_BUDGETS: dict[str, int] = {
|
||||
"minimal": 1024,
|
||||
"low": 4096,
|
||||
"medium": 8192,
|
||||
"high": 16384,
|
||||
"xhigh": 32768,
|
||||
"max": 32768,
|
||||
}
|
||||
# Anthropic rejects ``budget_tokens < 1024`` and ``budget_tokens >= max_tokens``.
|
||||
_ANTHROPIC_MIN_THINKING_BUDGET = 1024
|
||||
# Headroom left for the answer when a thinking budget has to raise max_tokens.
|
||||
_ANTHROPIC_MIN_ANSWER_TOKENS = 1024
|
||||
|
||||
|
||||
def _thinking_from_param(
|
||||
param: ChatCompletionsParam,
|
||||
*,
|
||||
thinking_budget_tokens: int,
|
||||
) -> AnthropicThinkingConfig | Unset:
|
||||
"""Build the ``thinking`` block for *param* (UNSET = send nothing).
|
||||
|
||||
``reasoning_effort = "none"`` disables thinking; any other level enables it
|
||||
with the explicit *thinking_budget_tokens*, else with the budget that level
|
||||
maps onto.
|
||||
"""
|
||||
effort = param.reasoning_effort
|
||||
if effort is UNSET:
|
||||
return UNSET
|
||||
if effort == "none":
|
||||
return AnthropicThinkingConfig(type="disabled")
|
||||
fallback = _ANTHROPIC_THINKING_BUDGETS["medium"]
|
||||
budget = thinking_budget_tokens or _ANTHROPIC_THINKING_BUDGETS.get(str(effort), fallback)
|
||||
return AnthropicThinkingConfig(
|
||||
type="enabled",
|
||||
budget_tokens=max(_ANTHROPIC_MIN_THINKING_BUDGET, int(budget)),
|
||||
)
|
||||
|
||||
|
||||
def _thinking_and_max_tokens(
|
||||
param: ChatCompletionsParam,
|
||||
*,
|
||||
thinking_budget_tokens: int,
|
||||
) -> tuple[int, AnthropicThinkingConfig | Unset]:
|
||||
"""Resolve the ``thinking`` block and the ``max_tokens`` that has to fit it.
|
||||
|
||||
Anthropic rejects ``budget_tokens >= max_tokens``; rather than silently
|
||||
shrink a requested reasoning budget, raise ``max_tokens`` so the answer still
|
||||
has room.
|
||||
"""
|
||||
max_tokens = _max_tokens_from_param(param)
|
||||
thinking = _thinking_from_param(param, thinking_budget_tokens=thinking_budget_tokens)
|
||||
if thinking is not UNSET and thinking.budget_tokens is not UNSET:
|
||||
budget = int(thinking.budget_tokens)
|
||||
if budget >= max_tokens:
|
||||
max_tokens = budget + _ANTHROPIC_MIN_ANSWER_TOKENS
|
||||
return max_tokens, thinking
|
||||
|
||||
|
||||
def chat_param_to_anthropic_param(
|
||||
param: ChatCompletionsParam,
|
||||
*,
|
||||
thinking_budget_tokens: int = 0,
|
||||
provider_tools: Sequence[dict[str, Any]] | None = None,
|
||||
) -> AnthropicMessagesParam:
|
||||
"""Build :class:`AnthropicMessagesParam` from a chat completions param.
|
||||
|
||||
*thinking_budget_tokens* is the configured explicit ``thinking`` budget
|
||||
(0 = derive it from the param's ``reasoning_effort``); *provider_tools* are
|
||||
hosted tools (web_search, …) as opaque dicts, merged after local function
|
||||
tools and never executed by the registry.
|
||||
"""
|
||||
system, messages = chat_messages_to_anthropic(param.messages)
|
||||
tools = tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None)
|
||||
tools: list[AnthropicToolDefinition | dict[str, Any]] = list(
|
||||
tool_items_to_anthropic_tools(param.tools if param.tools is not UNSET else None)
|
||||
)
|
||||
tools.extend(provider_tools_to_anthropic_tools(provider_tools))
|
||||
max_tokens, thinking = _thinking_and_max_tokens(param, thinking_budget_tokens=thinking_budget_tokens)
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": param.model,
|
||||
"max_tokens": _max_tokens_from_param(param),
|
||||
"max_tokens": max_tokens,
|
||||
"messages": messages,
|
||||
}
|
||||
if thinking is not UNSET:
|
||||
kwargs["thinking"] = thinking
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
if tools:
|
||||
|
||||
@@ -33,7 +33,7 @@ from plyngent.lmproto.anthropic.model import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
|
||||
from plyngent.lmproto.anthropic.client import AnthropicClient
|
||||
from plyngent.lmproto.anthropic.model import AnthropicStreamEvent
|
||||
@@ -44,6 +44,11 @@ if TYPE_CHECKING:
|
||||
)
|
||||
|
||||
|
||||
def _thinking_budget(client: AnthropicClient) -> int:
|
||||
"""Configured explicit ``thinking`` budget on *client* (0 = derive from effort)."""
|
||||
return max(0, int(getattr(client, "thinking_budget_tokens", 0)))
|
||||
|
||||
|
||||
class _ToolBlockState:
|
||||
"""Tracks an in-flight tool_use content block during SSE streaming."""
|
||||
|
||||
@@ -61,11 +66,17 @@ class _ToolBlockState:
|
||||
|
||||
|
||||
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."""
|
||||
"""Merge ``message_start`` usage with the later ``message_delta`` usage.
|
||||
|
||||
DeepSeek's compat reports a search turn's real prompt tokens only on the
|
||||
delta (``message_start`` sees the question alone, the delta counts what the
|
||||
search pulled in), so the larger of the two wins; output tokens are the
|
||||
delta's cumulative count either way.
|
||||
"""
|
||||
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)
|
||||
prompt = max(int(previous.get("prompt_tokens", 0) or 0), int(new.get("prompt_tokens", 0) or 0))
|
||||
completion = max(int(previous.get("completion_tokens", 0) or 0), int(new.get("completion_tokens", 0) or 0))
|
||||
return {
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
@@ -87,6 +98,7 @@ class _StreamState:
|
||||
|
||||
model: str
|
||||
tool_blocks: dict[int, _ToolBlockState]
|
||||
block_types: dict[int, str]
|
||||
next_tool_index: int
|
||||
stop_reason: str | None
|
||||
last_usage: dict[str, Any] | None
|
||||
@@ -96,6 +108,7 @@ class _StreamState:
|
||||
def __init__(self, model: str) -> None:
|
||||
self.model = model
|
||||
self.tool_blocks = {}
|
||||
self.block_types = {}
|
||||
self.next_tool_index = 0
|
||||
self.stop_reason = None
|
||||
self.last_usage = None
|
||||
@@ -121,11 +134,19 @@ class _StreamState:
|
||||
return self.start_tool(block_index=block_index, call_id=f"toolu_{block_index}", name="")
|
||||
|
||||
|
||||
def _handle_tool_use_start(
|
||||
def _handle_block_start(
|
||||
event: AnthropicContentBlockStart,
|
||||
state: _StreamState,
|
||||
) -> list[ChatCompletionChunk]:
|
||||
"""Record the block's type; only ``tool_use`` becomes a local tool call.
|
||||
|
||||
``server_tool_use`` (a search the model's backend runs), its
|
||||
``web_search_tool_result``, and ``thinking`` stream here too — recording
|
||||
their type keeps the argument fragments that follow from being mistaken for
|
||||
a local call's arguments (see ``_chunks_for_content_delta``).
|
||||
"""
|
||||
block = event.content_block
|
||||
state.block_types[event.index] = block.type
|
||||
if block.type != "tool_use":
|
||||
return []
|
||||
call_id = block.id or f"toolu_{event.index}"
|
||||
@@ -165,7 +186,7 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
|
||||
return []
|
||||
|
||||
if isinstance(event, AnthropicContentBlockStart):
|
||||
return _handle_tool_use_start(event, state)
|
||||
return _handle_block_start(event, state)
|
||||
|
||||
if isinstance(event, AnthropicContentBlockDelta):
|
||||
return _chunks_for_content_delta(event, state)
|
||||
@@ -194,6 +215,13 @@ def _chunks_for_content_delta(
|
||||
if not is_json_delta:
|
||||
return []
|
||||
|
||||
known_type = state.block_types.get(event.index)
|
||||
if known_type is not None and known_type != "tool_use":
|
||||
# A server tool's own arguments (DeepSeek streams the search query as
|
||||
# ``input_json_delta`` on a ``server_tool_use`` block): nothing local
|
||||
# asked for it, and its result arrived in this same response.
|
||||
return []
|
||||
|
||||
tool_state = state.ensure_tool(event.index)
|
||||
fragment = delta.partial_json if isinstance(delta.partial_json, str) else ""
|
||||
if not fragment:
|
||||
@@ -210,6 +238,8 @@ def _chunks_for_content_delta(
|
||||
async def _stream_as_chat_chunks(
|
||||
client: AnthropicClient,
|
||||
param: ChatCompletionsParam,
|
||||
*,
|
||||
provider_tools: Sequence[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[ChatCompletionChunk]:
|
||||
"""Yield chat-completions chunks from an Anthropic Messages SSE stream.
|
||||
|
||||
@@ -219,7 +249,11 @@ async def _stream_as_chat_chunks(
|
||||
``message_stop``. Errors raise ``RuntimeError`` so the agent loop can
|
||||
surface them as retryable failures.
|
||||
"""
|
||||
create = chat_param_to_anthropic_param(param)
|
||||
create = chat_param_to_anthropic_param(
|
||||
param,
|
||||
thinking_budget_tokens=_thinking_budget(client),
|
||||
provider_tools=provider_tools,
|
||||
)
|
||||
stream = await client.messages(create, stream=True)
|
||||
state = _StreamState(param.model)
|
||||
|
||||
@@ -246,12 +280,22 @@ async def dispatch_messages(
|
||||
client: AnthropicClient,
|
||||
param: ChatCompletionsParam,
|
||||
*,
|
||||
provider_tools: Sequence[dict[str, Any]] | None = None,
|
||||
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)
|
||||
"""Run one Anthropic Messages turn and return a chat-completions-shaped result.
|
||||
|
||||
*provider_tools* are hosted tools (web_search, …) as opaque dicts; the
|
||||
surface's own server-tool definition is built from them and the results
|
||||
arrive inside the same response, so nothing is executed locally.
|
||||
"""
|
||||
create = chat_param_to_anthropic_param(
|
||||
param,
|
||||
thinking_budget_tokens=_thinking_budget(client),
|
||||
provider_tools=provider_tools,
|
||||
)
|
||||
if stream:
|
||||
return _stream_as_chat_chunks(client, param)
|
||||
return _stream_as_chat_chunks(client, param, provider_tools=provider_tools)
|
||||
response = await client.messages(create, stream=False)
|
||||
return anthropic_response_to_chat_completion(response)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai.model import (
|
||||
ResponseEasyInputMessage,
|
||||
ResponseFunctionTool,
|
||||
ResponseFunctionToolCallOutput,
|
||||
ResponseReasoningConfig,
|
||||
response_function_calls,
|
||||
response_output_text,
|
||||
)
|
||||
@@ -40,6 +41,15 @@ if TYPE_CHECKING:
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
||||
from plyngent.typedef import Unset
|
||||
|
||||
# A round that kept no chain-of-thought still goes back with a ``reasoning``
|
||||
# item, holding a blank line: thinking mode pairs every round of the turn being
|
||||
# continued with its reasoning and rejects both a missing item and an empty
|
||||
# ``reasoning_text`` — "The ``reasoning_text`` in the thinking mode must be
|
||||
# passed back to the API" — while any non-empty text is accepted. A blank part
|
||||
# says nothing the round did not say; a line of prose here would put the
|
||||
# bridge's words in the model's own mouth.
|
||||
_BLANK_REASONING = " "
|
||||
|
||||
|
||||
def tool_items_to_response_tools(
|
||||
tools: Sequence[AnyToolItem] | None,
|
||||
@@ -63,11 +73,44 @@ def tool_items_to_response_tools(
|
||||
return result
|
||||
|
||||
|
||||
def _reasoning_item(text: str) -> dict[str, Any]:
|
||||
"""A ``reasoning`` input item holding *text* as its chain-of-thought."""
|
||||
return {"type": "reasoning", "content": [{"type": "reasoning_text", "text": text}]}
|
||||
|
||||
|
||||
def _assistant_to_input_items(
|
||||
message: AssistantChatMessage,
|
||||
) -> list[dict[str, Any] | ResponseEasyInputMessage]:
|
||||
"""Map one assistant message to Responses ``input`` items.
|
||||
|
||||
A round's items come back in the order the model produced them: the
|
||||
reasoning, then the text, then the calls. The ``reasoning`` item always
|
||||
holds text — the API merges it into the assistant message its round forms,
|
||||
and the turn being continued must return every round's chain-of-thought
|
||||
("必须完整回传 ``reasoning_content`` … 即使该轮模型未实际进行工具调用") — and
|
||||
a round that kept none sends a blank line, because an empty
|
||||
``reasoning_text`` and a missing item are both answered with a 400.
|
||||
|
||||
History holds rounds with no chain-of-thought at all — a ``rollback_tail``
|
||||
carrier, a forged ``todo_list`` call, the seed a compacted session starts
|
||||
with, rounds recorded before the reasoning was read back.
|
||||
|
||||
The part is ``reasoning_text``, the only content part the API documents for
|
||||
``reasoning`` items (``summary`` and ``encrypted_content`` are unsupported).
|
||||
"""
|
||||
items: list[dict[str, Any] | ResponseEasyInputMessage] = []
|
||||
if message.tool_calls is not UNSET and message.tool_calls:
|
||||
raw_reasoning = message.reasoning_content
|
||||
reasoning = raw_reasoning if isinstance(raw_reasoning, str) else ""
|
||||
tool_calls = message.tool_calls
|
||||
calls = (
|
||||
[call for call in tool_calls if isinstance(call, AssistantFunctionToolCall)] if tool_calls is not UNSET else []
|
||||
)
|
||||
content = message.content
|
||||
text = content if isinstance(content, str) and content else None
|
||||
if calls or text is not None or reasoning:
|
||||
items.append(_reasoning_item(reasoning or _BLANK_REASONING))
|
||||
if text is not None:
|
||||
items.append(ResponseEasyInputMessage(role="assistant", content=text))
|
||||
items.extend(
|
||||
{
|
||||
"type": "function_call",
|
||||
@@ -75,11 +118,8 @@ def _assistant_to_input_items(
|
||||
"name": call.function.name,
|
||||
"arguments": call.function.arguments,
|
||||
}
|
||||
for call in message.tool_calls
|
||||
if isinstance(call, AssistantFunctionToolCall)
|
||||
for call in calls
|
||||
)
|
||||
if isinstance(message.content, str) and message.content:
|
||||
items.append(ResponseEasyInputMessage(role="assistant", content=message.content))
|
||||
return items
|
||||
|
||||
|
||||
@@ -95,17 +135,20 @@ def _item_call_id(
|
||||
return None
|
||||
|
||||
|
||||
def _interleave_tool_outputs(
|
||||
def _group_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``.
|
||||
"""Place a round's ``function_call_output`` items after all of its calls.
|
||||
|
||||
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).
|
||||
The API reads a round as the assistant message its ``reasoning`` and
|
||||
``function_call`` items merge into, so a round's calls must stay contiguous:
|
||||
sent as ``call, output, call, output``, the second call becomes a round of
|
||||
its own without reasoning and the request is answered with "The
|
||||
``reasoning_text`` in the thinking mode must be passed back to the API".
|
||||
Grouping also keeps each output next to its own call when another item (a
|
||||
developer notice) was recorded between them — an output that trails an
|
||||
unrelated assistant message is rejected with "No tool output found for tool
|
||||
call …".
|
||||
"""
|
||||
outputs_by_call: dict[str, list[ResponseFunctionToolCallOutput]] = {}
|
||||
for item in items:
|
||||
@@ -113,16 +156,22 @@ def _interleave_tool_outputs(
|
||||
outputs_by_call.setdefault(item.call_id, []).append(item)
|
||||
|
||||
out: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput] = []
|
||||
pending: list[str] = []
|
||||
for item in items:
|
||||
if isinstance(item, ResponseFunctionToolCallOutput):
|
||||
continue # re-added right after their call below
|
||||
out.append(item)
|
||||
continue # re-added after the round's last call below
|
||||
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))
|
||||
if call_id is None:
|
||||
out.extend(output for pending_id in pending for output in outputs_by_call.pop(pending_id, []))
|
||||
pending.clear()
|
||||
out.append(item)
|
||||
if call_id is not None:
|
||||
pending.append(call_id)
|
||||
for pending_id in pending:
|
||||
out.extend(outputs_by_call.pop(pending_id, []))
|
||||
# Defensive: outputs whose call was not seen keep stream order at the end.
|
||||
for pending in outputs_by_call.values():
|
||||
out.extend(pending)
|
||||
for pending_outputs in outputs_by_call.values():
|
||||
out.extend(pending_outputs)
|
||||
return out
|
||||
|
||||
|
||||
@@ -155,7 +204,7 @@ def chat_messages_to_responses_input(
|
||||
)
|
||||
|
||||
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
|
||||
return instructions, _interleave_tool_outputs(items)
|
||||
return instructions, _group_tool_outputs(items)
|
||||
|
||||
|
||||
def response_to_assistant_message(response: Response) -> AssistantChatMessage:
|
||||
@@ -188,7 +237,7 @@ def _reasoning_text_blocks(raw: object) -> list[str]:
|
||||
if not isinstance(block_obj, dict):
|
||||
continue
|
||||
block_map = cast("dict[str, object]", block_obj)
|
||||
if block_map.get("type") in {"summary_text", "output_text", "reasoning_content"}:
|
||||
if block_map.get("type") in {"summary_text", "output_text", "reasoning_text", "reasoning_content"}:
|
||||
text = block_map.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
@@ -196,12 +245,14 @@ def _reasoning_text_blocks(raw: object) -> list[str]:
|
||||
|
||||
|
||||
def reasoning_summary_text(response: Response) -> str:
|
||||
"""Concatenate reasoning text from the response.
|
||||
"""Concatenate the chain-of-thought text of a completed response.
|
||||
|
||||
OpenAI puts ``reasoning`` items in ``output`` with a ``summary`` block list.
|
||||
DeepSeek (Responses convention) additionally returns the full chain-of-thought
|
||||
in the top-level ``response.reasoning`` (``content``/``summary`` block lists);
|
||||
prefer it when present so the two sources are not duplicated.
|
||||
A reasoning item carries it in ``content`` — ``reasoning_text`` parts, with
|
||||
``summary`` left empty (DeepSeek's documented shape; OpenAI streams a
|
||||
summary instead). DeepSeek (Responses convention) may also return the full
|
||||
chain-of-thought in the top-level ``response.reasoning`` (``content`` /
|
||||
``summary`` block lists); prefer it when present so the two sources are not
|
||||
duplicated.
|
||||
"""
|
||||
reasoning = response.reasoning
|
||||
if isinstance(reasoning, dict):
|
||||
@@ -212,7 +263,7 @@ def reasoning_summary_text(response: Response) -> str:
|
||||
for raw in response.output:
|
||||
if raw.get("type") != "reasoning":
|
||||
continue
|
||||
parts.extend(_reasoning_text_blocks(raw.get("summary")))
|
||||
parts.extend(_reasoning_text_blocks(raw.get("content")) + _reasoning_text_blocks(raw.get("summary")))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
@@ -336,6 +387,8 @@ def chat_param_to_responses_kwargs(
|
||||
kwargs["max_output_tokens"] = param.max_tokens
|
||||
if param.parallel_tool_calls is not UNSET:
|
||||
kwargs["parallel_tool_calls"] = param.parallel_tool_calls
|
||||
if param.reasoning_effort is not UNSET:
|
||||
kwargs["reasoning"] = ResponseReasoningConfig(effort=param.reasoning_effort)
|
||||
if param.tool_choice is not UNSET and isinstance(param.tool_choice, str):
|
||||
kwargs["tool_choice"] = param.tool_choice
|
||||
return kwargs
|
||||
|
||||
@@ -107,7 +107,7 @@ class TodoStack:
|
||||
return self._data
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, raw: object | None) -> TodoStack: # noqa: C901, PLR0911
|
||||
def from_raw(cls, raw: object | None) -> TodoStack: # noqa: C901, PLR0911 — three stored stack shapes
|
||||
if raw is None or not isinstance(raw, dict):
|
||||
return cls()
|
||||
blob = cast("dict[str, object]", raw)
|
||||
|
||||
@@ -218,7 +218,7 @@ async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
|
||||
return EXIT_OK if ok else EXIT_TURN_FAILED
|
||||
|
||||
|
||||
async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
|
||||
*,
|
||||
config_path: Path | None,
|
||||
provider_name: str | None,
|
||||
|
||||
+99
-21
@@ -182,9 +182,10 @@ class PrettyLine:
|
||||
``prefix`` is written as soon as the call starts — a blocking tool call is
|
||||
then visible while it runs — and ``detail`` is appended once its result
|
||||
lands, so the two land on one line. Together they equal the single line the
|
||||
whole-line path prints. When something else is printed in between (a
|
||||
parallel call, a confirm/ask prompt) the prefix stays as its own line and
|
||||
the result is printed as a complete line instead.
|
||||
whole-line path prints. A later call of the same round waits its turn (see
|
||||
:class:`_PrettyToolStream`); only when *other* output takes the line — a
|
||||
confirm/ask prompt — does the prefix stay as its own line and the result
|
||||
print as a complete line instead.
|
||||
"""
|
||||
|
||||
prefix: Callable[[str], str]
|
||||
@@ -224,6 +225,8 @@ _TODO_LABELS: dict[str, str] = {
|
||||
"todo_clear": "Todo Clear",
|
||||
}
|
||||
|
||||
_TODO_TOOLS: frozenset[str] = frozenset(_TODO_LABELS)
|
||||
|
||||
|
||||
def _todo_line(name: str) -> PrettyLine:
|
||||
"""Todo tools: header first, the rendered stack as the detail."""
|
||||
@@ -238,6 +241,18 @@ def _todo_line(name: str) -> PrettyLine:
|
||||
return PrettyLine(prefix=prefix, detail=detail)
|
||||
|
||||
|
||||
def _echo_todo_batch(labels: list[str], detail: str) -> None:
|
||||
"""One line for a batch of todo calls, then the last result (its stack).
|
||||
|
||||
A model that updates its list in one round used to print one header and a
|
||||
full stack render per call; the last result already carries the final stack,
|
||||
so one summary line plus that result says the same thing in fewer rows.
|
||||
"""
|
||||
ops = ", ".join(label.removeprefix("Todo ") for label in labels)
|
||||
click.secho(f"\n* Todo x{len(labels)}: {ops}", fg="yellow")
|
||||
click.echo(detail)
|
||||
|
||||
|
||||
def _tree_line_stats(fmt: str, line: str) -> tuple[str, int] | None:
|
||||
"""Classify one ``tree`` result line as ``(kind, depth)`` or None (not an entry).
|
||||
|
||||
@@ -408,6 +423,27 @@ def _fetch_detail(_args_json: str, result: str) -> str:
|
||||
return _pretty_segments((f"({status or 'done'})", _http_status_fg(status)))
|
||||
|
||||
|
||||
# ``web_search`` reports "every source found nothing (or failed)" as an error, so
|
||||
# the renderer splits that emptiness off the failures that are real errors.
|
||||
_WEB_SEARCH_EMPTY = "error: the search found nothing"
|
||||
|
||||
|
||||
def _web_search_prefix(args_json: str) -> str:
|
||||
query = _json_str_arg(args_json, "query") or "?"
|
||||
return _pretty_prefix(f"* Web Search '{_preview(query, _ASK_SUBJECT_PREVIEW)}' ")
|
||||
|
||||
|
||||
def _web_search_detail(_args_json: str, result: str) -> str:
|
||||
"""``web_search`` outcome: hit count, nothing found, or a failing source."""
|
||||
if result.startswith(_WEB_SEARCH_EMPTY):
|
||||
return _pretty_segments(("(no results)", "dim"))
|
||||
if result.startswith("error:"):
|
||||
return _pretty_segments((f"({result})", "red"))
|
||||
hits = sum(1 for line in result.splitlines() if line.startswith("- "))
|
||||
unit = "hit" if hits == 1 else "hits"
|
||||
return _pretty_segments((f"({hits} {unit})", None))
|
||||
|
||||
|
||||
_MUTATOR_VERBS: dict[str, str] = {
|
||||
"edit_replace": "Edit",
|
||||
"edit_lineno": "Edit",
|
||||
@@ -810,6 +846,7 @@ _PRETTY_LINES: dict[str, PrettyLine] = {
|
||||
"run_argv": PrettyLine(prefix=_run_argv_prefix, detail=_run_argv_detail),
|
||||
"run_argv_batch": PrettyLine(prefix=_run_argv_batch_prefix, detail=_run_argv_batch_detail),
|
||||
"fetch": PrettyLine(prefix=_fetch_prefix, detail=_fetch_detail),
|
||||
"web_search": PrettyLine(prefix=_web_search_prefix, detail=_web_search_detail),
|
||||
"edit_replace": _mutator_line("edit_replace"),
|
||||
"edit_lineno": _mutator_line("edit_lineno"),
|
||||
"write_file": _mutator_line("write_file"),
|
||||
@@ -927,37 +964,38 @@ class _PrettyToolStream:
|
||||
|
||||
Writing the prefix immediately keeps a blocking tool call visible; appending
|
||||
the detail later reproduces the single whole-line write byte for byte, so
|
||||
non-interactive output is unchanged. Nothing ever moves the cursor: when
|
||||
something else is printed in between — a second call of the batch (parallel
|
||||
tools) or a confirm/ask prompt — the open prefix is *closed* as its own line
|
||||
and the rest of the batch prints whole lines (``prefix + detail``). A prefix
|
||||
wider than the terminal therefore degrades cleanly instead of leaving a
|
||||
half-erased row behind.
|
||||
non-interactive output is unchanged. Nothing ever moves the cursor: a round
|
||||
that issues several calls holds the prefixes of the later ones until the open
|
||||
row takes its own detail (results arrive in call order), so every call ends
|
||||
up with exactly one finished row. Output that genuinely takes the line — a
|
||||
confirm/ask prompt, streamed text, an error — closes the open prefix as its
|
||||
own line instead and the rest of the batch prints whole lines; a prefix wider
|
||||
than the terminal thus degrades cleanly rather than leaving a half-erased row
|
||||
behind.
|
||||
"""
|
||||
|
||||
_interactive: bool
|
||||
_open: bool
|
||||
_whole_lines: bool
|
||||
_queued: list[str]
|
||||
|
||||
def __init__(self, *, interactive: bool) -> None:
|
||||
self._interactive = interactive
|
||||
self._open = False
|
||||
self._whole_lines = False
|
||||
self._queued = []
|
||||
|
||||
def start_call(self, prefix: str) -> None:
|
||||
"""Show *prefix* for a call that is about to run (cursor left mid-line)."""
|
||||
if not (self._interactive and prefix) or self._whole_lines:
|
||||
return
|
||||
if self._open:
|
||||
# Parallel batch: the open prefix can never receive its detail, so
|
||||
# end it as its own line and print whole lines from here on.
|
||||
self.close_line()
|
||||
# Sibling call of the same round: hold its prefix back instead of
|
||||
# closing the open row, which would leave the earlier call as an
|
||||
# abandoned half-line and repeat its prefix on the whole line.
|
||||
self._queued.append(prefix)
|
||||
return
|
||||
click.echo(f"\n{prefix}", nl=False)
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stdout.flush()
|
||||
self._open = True
|
||||
_mark_open_prefix(self)
|
||||
self._show_prefix(prefix)
|
||||
|
||||
def finish_call(self, detail: str) -> bool:
|
||||
"""Append *detail* to the open prefix; False when a whole line is needed."""
|
||||
@@ -966,10 +1004,14 @@ class _PrettyToolStream:
|
||||
click.echo(f"{detail}\n", nl=False)
|
||||
self._open = False
|
||||
_mark_open_prefix(None)
|
||||
if self._queued:
|
||||
# This row is complete, so the next call of the batch can have its own.
|
||||
self._show_prefix(self._queued.pop(0))
|
||||
return True
|
||||
|
||||
def close_line(self) -> None:
|
||||
"""End a dangling prefix line before other output takes the next line."""
|
||||
self._queued.clear()
|
||||
if not self._open:
|
||||
return
|
||||
click.echo()
|
||||
@@ -982,6 +1024,14 @@ class _PrettyToolStream:
|
||||
self.close_line()
|
||||
self._whole_lines = False
|
||||
|
||||
def _show_prefix(self, prefix: str) -> None:
|
||||
"""Write *prefix* and leave the cursor on it for its own detail."""
|
||||
click.echo(f"\n{prefix}", nl=False)
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stdout.flush()
|
||||
self._open = True
|
||||
_mark_open_prefix(self)
|
||||
|
||||
|
||||
# The stream with a prefix line still open (cursor mid-line), or None. A confirm
|
||||
# box or ``ask_user_*`` prompt is printed from a worker thread while the tool
|
||||
@@ -1033,7 +1083,7 @@ def _flush_assistant_markdown(body: str, *, pretty: bool) -> None:
|
||||
click.echo()
|
||||
|
||||
|
||||
async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
async def render_events( # noqa: C901, PLR0915 — one branch per event type
|
||||
events: AsyncIterator[AgentEvent],
|
||||
*,
|
||||
verbose: bool | None = None,
|
||||
@@ -1048,7 +1098,8 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
|
||||
Pretty tool calls print their ``* Verb 'target' `` prefix as soon as the call
|
||||
starts, so a blocking tool is visible while it runs; the outcome is appended
|
||||
when the result lands (same bytes as the whole-line fallback).
|
||||
when the result lands (same bytes as the whole-line fallback). A round that
|
||||
issues several calls renders them in call order, one finished row each.
|
||||
"""
|
||||
show_full = get_verbose_tool_results() if verbose is None else verbose
|
||||
use_markdown = get_markdown_enabled() if markdown is None else markdown
|
||||
@@ -1062,6 +1113,12 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
# Tool calls buffer so prettified tools can render once their result lands
|
||||
# (the summary line needs the status/range). FIFO matches loop event order.
|
||||
pending_tools: list[tuple[str, str, PrettyLine | None]] = []
|
||||
# A batch (one round's tool calls) that only touches todos prints one summary
|
||||
# line plus its last result, whose text carries the final stack. Collected as
|
||||
# the labels seen so far and that result's detail, flushed when the batch ends.
|
||||
todo_labels: list[str] = []
|
||||
merged_todo = False
|
||||
merged_detail: str | None = None
|
||||
|
||||
def flush_assistant() -> None:
|
||||
nonlocal source, assistant_buf, printed_assistant
|
||||
@@ -1115,14 +1172,25 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
name = call.function.name
|
||||
args = call.function.arguments
|
||||
line = _pretty_line_for(name)
|
||||
if not pending_tools:
|
||||
# First call of a batch: a batch that only touches todos is
|
||||
# printed as one merged block once its last result lands.
|
||||
todo_labels.clear()
|
||||
merged_todo = name in _TODO_TOOLS
|
||||
pending_tools.append((name, args, line))
|
||||
if line is not None:
|
||||
if name in _TODO_TOOLS:
|
||||
if merged_todo:
|
||||
todo_labels.append(_TODO_LABELS[name])
|
||||
elif line is not None:
|
||||
merged_todo = False
|
||||
tool_lines.start_call(line.prefix(args))
|
||||
else:
|
||||
merged_todo = False
|
||||
tool_lines.close_line()
|
||||
preview = _preview(args, _TOOL_ARGS_PREVIEW)
|
||||
click.secho(f"\n[tool] {name}({preview})", fg="yellow")
|
||||
else:
|
||||
merged_todo = False
|
||||
tool_lines.close_line()
|
||||
pending_tools.append(("custom", call.id, None))
|
||||
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
|
||||
@@ -1133,7 +1201,14 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
parts = _pretty_parts(line, args, content) if line is not None else None
|
||||
if parts is not None and not show_full:
|
||||
prefix, detail = parts
|
||||
if not tool_lines.finish_call(detail):
|
||||
if name in _TODO_TOOLS:
|
||||
if merged_todo and len(todo_labels) > 1:
|
||||
# Merged batch: the batch end prints the last result.
|
||||
merged_detail = detail
|
||||
else:
|
||||
tool_lines.close_line()
|
||||
click.echo(f"\n{prefix}{detail}")
|
||||
elif not tool_lines.finish_call(detail):
|
||||
click.echo(f"\n{prefix}{detail}")
|
||||
elif show_full:
|
||||
tool_lines.close_line()
|
||||
@@ -1143,6 +1218,9 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
preview = _preview_result(content, _TOOL_RESULT_PREVIEW)
|
||||
click.secho(f"[tool ok] {preview}", fg="magenta")
|
||||
if not pending_tools:
|
||||
if merged_detail is not None:
|
||||
_echo_todo_batch(todo_labels, merged_detail)
|
||||
merged_detail = None
|
||||
tool_lines.end_batch()
|
||||
elif isinstance(event, ErrorEvent):
|
||||
flush_assistant()
|
||||
|
||||
@@ -37,6 +37,9 @@ _MINIMAL_CONFIG = """\
|
||||
# confirm_destructive = true
|
||||
# path_denylist = ["/secrets/", ".ssh/"]
|
||||
# max_context_tokens = 200000
|
||||
# # Thinking strength: "" (provider default) | none | minimal | low | medium |
|
||||
# # high | xhigh | max; providers and models may override it.
|
||||
# # reasoning_effort = "medium"
|
||||
#
|
||||
# # Optional compact prompts (empty = use built-in defaults).
|
||||
# # compact_system_prompt = ""
|
||||
@@ -54,7 +57,7 @@ _MINIMAL_CONFIG = """\
|
||||
# [providers.deepseek]
|
||||
# preset = "deepseek"
|
||||
# access_key_or_token = "sk-..."
|
||||
# # models default to deepseek-v4-flash and deepseek-v4-pro if omitted
|
||||
# # models default to deepseek-flash and deepseek-v4-pro if omitted
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -112,9 +112,10 @@ HISTORY_ROW_NUMBER = HistoryRowNumberType()
|
||||
|
||||
|
||||
HELP_FOOTER = (
|
||||
"User messages are saved immediately. On API errors or Ctrl+C, partial\n"
|
||||
"assistant/tool output is discarded but the user message stays (so /retry\n"
|
||||
"works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n"
|
||||
"User messages are saved immediately. On API errors or Ctrl+C, unfinished\n"
|
||||
"assistant text is discarded (tool results that already ran stay, so /retry\n"
|
||||
"does not re-run them) but the user message stays (so /retry works after\n"
|
||||
"resume, not only via readline history). Auto-retry: 5s/10s/15s/20s, then +10s.\n"
|
||||
"\n"
|
||||
"Side questions: /btw … answers without changing the main session\n"
|
||||
"(--tools=read by default: read-only tools; --tools no/full opt-in; --fresh).\n"
|
||||
@@ -851,7 +852,7 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
|
||||
help="Merge remote (and recovered) model ids into plyngent.toml for this provider.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901
|
||||
def models_cmd(state: ReplState, *, refresh: bool, persist: bool) -> None: # noqa: C901 — list / recover / persist
|
||||
"""List models (remote-first, plus config-only ids). Always tries GET /models."""
|
||||
del refresh # always re-fetch; flag kept for CLI compatibility / docs
|
||||
remote: list[str] | None = None
|
||||
@@ -1511,7 +1512,7 @@ def history_cmd(
|
||||
@click.argument("action", required=False, type=TODOS_ACTION)
|
||||
@click.argument("rest", required=False, nargs=-1, type=str)
|
||||
@click.pass_obj
|
||||
def todos_cmd( # noqa: C901, PLR0911, PLR0912, PLR0915
|
||||
def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
|
||||
state: ReplState, action: str | None, rest: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Show or edit the LIFO stack of **task groups**.
|
||||
|
||||
@@ -13,7 +13,9 @@ from plyngent.agent.todo_stack import TodoStack
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from plyngent.agent.tools import ToolDefinition
|
||||
from plyngent.agent.types import AnyLLMClient
|
||||
from plyngent.tools.net.search import SearchBackend
|
||||
from plyngent.cli.models_source import (
|
||||
DEFAULT_MODELS_CACHE_TTL,
|
||||
client_supports_models,
|
||||
@@ -21,7 +23,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.config import EffectiveProvider, ReasoningConfig, resolve_effective_provider, resolve_reasoning
|
||||
from plyngent.memory.database.store import normalize_workspace
|
||||
from plyngent.runtime import create_client
|
||||
from plyngent.tools import InstanceState, SessionState
|
||||
@@ -101,7 +103,7 @@ class ReplState:
|
||||
_remote_models_error: str | None = field(default=None, init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.client = create_client(self.provider, model=self.model)
|
||||
self.client = create_client(self.provider, model=self.model, reasoning=self.reasoning)
|
||||
self.workspace = Path(self.workspace).expanduser().resolve()
|
||||
self.instance_state.workspace_root = self.workspace
|
||||
self.instance_state.workspace.root = self.workspace
|
||||
@@ -398,7 +400,7 @@ class ReplState:
|
||||
tools = catalog.select(surface="local", exclude_names=excluded)
|
||||
yolo = self.effective_yolo() != "off"
|
||||
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
|
||||
return ToolRegistry(
|
||||
registry = ToolRegistry(
|
||||
tools,
|
||||
danger=classify_danger,
|
||||
on_confirm=prompt_confirm_tool_async,
|
||||
@@ -407,6 +409,51 @@ class ReplState:
|
||||
instance_state=self.instance_state,
|
||||
session_state=self.session_state,
|
||||
)
|
||||
search_tool = self._web_search_tool()
|
||||
if search_tool is not None:
|
||||
registry.register(search_tool)
|
||||
return registry
|
||||
|
||||
def _web_search_tool(self) -> ToolDefinition | None:
|
||||
"""Local ``web_search`` for a provider whose surface cannot host one.
|
||||
|
||||
The sources come from ``provider.search_sources``, in fallback order
|
||||
(see :func:`~plyngent.config.routing.local_web_search`).
|
||||
"""
|
||||
from plyngent.config.routing import (
|
||||
deepseek_search_base_url,
|
||||
local_web_search,
|
||||
)
|
||||
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
||||
from plyngent.lmproto.deepseek.anthropic.search import DeepseekSearchClient
|
||||
from plyngent.tools.net.bing import BingSearchBackend
|
||||
from plyngent.tools.net.search import (
|
||||
DeepseekSearchBackend,
|
||||
build_web_search_tool,
|
||||
)
|
||||
|
||||
provider = self.effective_provider
|
||||
if not local_web_search(provider) or not self.model:
|
||||
return None
|
||||
backends: list[SearchBackend] = []
|
||||
for source in provider.search_sources:
|
||||
if source == "deepseek":
|
||||
backends.append(
|
||||
DeepseekSearchBackend(
|
||||
DeepseekSearchClient(
|
||||
AnthropicConfig(
|
||||
api_key=provider.access_key_or_token,
|
||||
base_url=deepseek_search_base_url(provider),
|
||||
)
|
||||
),
|
||||
model=self.model,
|
||||
)
|
||||
)
|
||||
elif source == "bing":
|
||||
backends.append(BingSearchBackend())
|
||||
if not backends:
|
||||
return None
|
||||
return build_web_search_tool(backends)
|
||||
|
||||
def _mcp_instructions_text(self) -> str:
|
||||
"""Compose connected MCP servers' initialize ``instructions`` into one block.
|
||||
@@ -447,6 +494,7 @@ class ReplState:
|
||||
memory=self.memory,
|
||||
session_id=self.session_id,
|
||||
max_rounds=self.max_rounds,
|
||||
reasoning_effort=self.reasoning.effort,
|
||||
on_limit=on_limit,
|
||||
stream=self.stream_enabled,
|
||||
system_prompt=system_prompt,
|
||||
@@ -472,7 +520,7 @@ class ReplState:
|
||||
# Preserve live stream toggle if agent already exists.
|
||||
if hasattr(self, "agent"):
|
||||
self.stream_enabled = self.agent.stream
|
||||
self.client = create_client(self.provider, model=self.model)
|
||||
self.client = create_client(self.provider, model=self.model, reasoning=self.reasoning)
|
||||
self.agent = self._make_agent()
|
||||
# Restore history without re-marking already-stored messages as dirty.
|
||||
self.agent.replace_messages(messages, persist_from=persist_from)
|
||||
@@ -493,6 +541,14 @@ class ReplState:
|
||||
"""Selected provider after model-level preset/url overrides."""
|
||||
return resolve_effective_provider(self.provider, model=self.model)
|
||||
|
||||
@property
|
||||
def reasoning(self) -> ReasoningConfig:
|
||||
"""Merged thinking settings for the selected provider + model.
|
||||
|
||||
``[agent]`` < provider < model; see :mod:`plyngent.config.reasoning`.
|
||||
"""
|
||||
return resolve_reasoning(self.config.agent_config, self.provider, self.model)
|
||||
|
||||
def invalidate_remote_models(self) -> None:
|
||||
"""Drop cached remote model catalog (provider/client change)."""
|
||||
self._remote_models = None
|
||||
|
||||
@@ -21,10 +21,13 @@ 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 ReasoningEffortConfig as ReasoningEffortConfig
|
||||
from .models import SkillDiscoverySource as SkillDiscoverySource
|
||||
from .models import SkillsConfig as SkillsConfig
|
||||
from .models import compose_agent_system_content as compose_agent_system_content
|
||||
from .path import get_default_path as _get_default_path
|
||||
from .reasoning import ReasoningConfig as ReasoningConfig
|
||||
from .reasoning import resolve_reasoning as resolve_reasoning
|
||||
from .routing import EffectiveProvider as EffectiveProvider
|
||||
from .routing import ProviderPreset as ProviderPreset
|
||||
from .routing import default_url_for_preset as default_url_for_preset
|
||||
|
||||
@@ -9,10 +9,20 @@ from msgspec import Struct, field
|
||||
# - "anthropic" → Anthropic Messages API (``POST /messages`` on
|
||||
# ``https://api.deepseek.com/anthropic``)
|
||||
type DeepSeekConvention = Literal["", "openai", "anthropic", "responses"]
|
||||
# Sources the local ``web_search`` tool can use (see ``SearchBackend``).
|
||||
type SearchSource = Literal["deepseek", "bing"]
|
||||
|
||||
# Static directory pre-allow mode for ``[agent].allow_paths`` (path → mode).
|
||||
type AllowPathMode = Literal["read", "write", "exec"]
|
||||
|
||||
# Thinking strength, normalized across API surfaces (``reasoning_effort`` on
|
||||
# ``[agent]``, a provider, or a single model; the most specific one wins).
|
||||
# "" = send nothing and let the provider pick its own default.
|
||||
# Mapped per surface by ``config.reasoning``: chat completions / Responses send
|
||||
# ``reasoning_effort`` / ``reasoning.effort`` (``max`` is DeepSeek-only and
|
||||
# clamps to ``xhigh`` elsewhere), Anthropic maps it onto ``thinking``.
|
||||
type ReasoningEffortConfig = Literal["", "none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
# Built-in skill discovery sources (``[skills].discover``): our own user config
|
||||
# directory, and the skill directories other harnesses install into.
|
||||
type SkillDiscoverySource = Literal["plyngent", "claude"]
|
||||
@@ -150,6 +160,14 @@ class AgentConfig(Struct, omit_defaults=True):
|
||||
compact_user_prefix: str = ""
|
||||
compact_seed_text: str = ""
|
||||
|
||||
# Thinking strength for every request (a provider or single model may
|
||||
# override either value; the most specific one wins).
|
||||
# reasoning_effort: "" | none | minimal | low | medium | high | xhigh | max.
|
||||
# thinking_budget_tokens: explicit Anthropic ``thinking`` budget (0 = derive
|
||||
# it from ``reasoning_effort``). See config/reasoning.py.
|
||||
reasoning_effort: ReasoningEffortConfig = ""
|
||||
thinking_budget_tokens: int = 0
|
||||
|
||||
|
||||
class PluginsConfig(Struct, omit_defaults=True):
|
||||
"""Third-party plugins (not tool-specific config).
|
||||
@@ -268,6 +286,9 @@ class ModelConfig(Struct, omit_defaults=True):
|
||||
url: str = ""
|
||||
# DeepSeek API surface override ("" = inherit provider convention).
|
||||
convention: DeepSeekConvention = ""
|
||||
# Thinking strength for this model only ("" / 0 = inherit the provider).
|
||||
reasoning_effort: ReasoningEffortConfig = ""
|
||||
thinking_budget_tokens: int = 0
|
||||
|
||||
|
||||
class HttpTimeoutConfig(Struct, omit_defaults=True):
|
||||
@@ -299,6 +320,12 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
|
||||
models: dict[str, ModelConfig] = field(default_factory=dict)
|
||||
# float = single timeout for the session; table = connect/read split; omit = defaults.
|
||||
timeout: float | HttpTimeoutConfig | None = None
|
||||
# Thinking strength for every model of this provider ("" / 0 = inherit [agent]).
|
||||
reasoning_effort: ReasoningEffortConfig = ""
|
||||
thinking_budget_tokens: int = 0
|
||||
# Sources the local ``web_search`` tool tries, in fallback order (``[]``
|
||||
# disables the tool; see ``config.routing.local_web_search``).
|
||||
search_sources: list[SearchSource] = field(default_factory=list)
|
||||
|
||||
|
||||
def _default_openai_models() -> dict[str, ModelConfig]:
|
||||
@@ -310,8 +337,8 @@ def _default_openai_models() -> dict[str, ModelConfig]:
|
||||
}
|
||||
|
||||
|
||||
def _default_openai_provider_tools() -> list[dict[str, Any]]:
|
||||
"""Hosted tools when TOML omits ``provider_tools``."""
|
||||
def _default_provider_tools() -> list[dict[str, Any]]:
|
||||
"""Hosted tools when TOML omits ``provider_tools`` (OpenAI and DeepSeek)."""
|
||||
return [{"type": "web_search"}]
|
||||
|
||||
|
||||
@@ -330,7 +357,7 @@ class OpenAIProvider(ProviderConfig, tag="openai"):
|
||||
"""
|
||||
|
||||
models: dict[str, ModelConfig] = field(default_factory=_default_openai_models)
|
||||
provider_tools: list[dict[str, Any]] = field(default_factory=_default_openai_provider_tools)
|
||||
provider_tools: list[dict[str, Any]] = field(default_factory=_default_provider_tools)
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(ProviderConfig, tag="openai-compatible"):
|
||||
@@ -344,11 +371,20 @@ class AnthropicProvider(ProviderConfig, tag="anthropic"):
|
||||
def _default_deepseek_models() -> dict[str, ModelConfig]:
|
||||
"""Current DeepSeek text catalog when TOML omits ``models``."""
|
||||
return {
|
||||
"deepseek-v4-flash": ModelConfig(text=True),
|
||||
"deepseek-flash": ModelConfig(text=True),
|
||||
"deepseek-v4-pro": ModelConfig(text=True),
|
||||
}
|
||||
|
||||
|
||||
def _default_deepseek_search_sources() -> list[SearchSource]:
|
||||
"""Sources for the local ``web_search`` tool, in fallback order.
|
||||
|
||||
DeepSeek's own index first (it is the provider's own search), Bing's RSS as
|
||||
the backstop when a search fails or finds nothing.
|
||||
"""
|
||||
return ["deepseek", "bing"]
|
||||
|
||||
|
||||
class DeepseekProvider(ProviderConfig, tag="deepseek"):
|
||||
"""DeepSeek API provider.
|
||||
|
||||
@@ -358,12 +394,21 @@ class DeepseekProvider(ProviderConfig, tag="deepseek"):
|
||||
- ``"anthropic"`` → Anthropic Messages API (``POST /messages`` on
|
||||
``https://api.deepseek.com/anthropic``)
|
||||
|
||||
``provider_tools`` asks for a search tool like OpenAI's does (``[]``
|
||||
disables it). DeepSeek runs a hosted search only on the Anthropic surface, so
|
||||
the other conventions get a local ``web_search`` tool instead (see
|
||||
``config.routing.local_web_search``); ``search_sources`` names what that tool
|
||||
searches with, in fallback order — ``["deepseek", "bing"]`` by default,
|
||||
``[]`` to not offer it.
|
||||
|
||||
``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 = ""
|
||||
provider_tools: list[dict[str, Any]] = field(default_factory=_default_provider_tools)
|
||||
search_sources: list[SearchSource] = field(default_factory=_default_deepseek_search_sources)
|
||||
extras: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Thinking-strength resolution across config layers and API surfaces.
|
||||
|
||||
``reasoning_effort`` (and the optional Anthropic ``thinking_budget_tokens``) may
|
||||
be set on ``[agent]``, on a provider, or on a single model. This module merges
|
||||
the layers — the most specific one wins, per field — and normalizes the result
|
||||
for the API surface the provider actually talks to, so the agent loop and the
|
||||
provider bridges receive one value with a single meaning:
|
||||
|
||||
* ``""`` — send nothing; the provider applies its own default.
|
||||
* chat completions / Responses — ``reasoning_effort`` / ``reasoning.effort``.
|
||||
* Anthropic — ``thinking`` (an effort level has no wire form there).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .routing import model_config_for, resolve_effective_provider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import AgentConfig, Provider, ReasoningEffortConfig
|
||||
|
||||
# DeepSeek's own ``reasoning_effort`` accepts "max"; OpenAI and Anthropic do not,
|
||||
# so anywhere else it clamps down to the highest level they do understand.
|
||||
_DEEPSEEK_CHAT_CONVENTIONS = frozenset({"", "openai"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReasoningConfig:
|
||||
"""Merged thinking settings for one provider + model."""
|
||||
|
||||
effort: ReasoningEffortConfig = ""
|
||||
# Explicit Anthropic ``thinking`` budget; 0 = derive it from ``effort``.
|
||||
thinking_budget_tokens: int = 0
|
||||
|
||||
|
||||
def _clamp_effort(effort: ReasoningEffortConfig, *, preset: str, convention: str) -> ReasoningEffortConfig:
|
||||
"""Keep ``max`` only on the DeepSeek chat surface that defines it."""
|
||||
if effort != "max":
|
||||
return effort
|
||||
if preset == "deepseek" and convention in _DEEPSEEK_CHAT_CONVENTIONS:
|
||||
return effort
|
||||
return "xhigh"
|
||||
|
||||
|
||||
def resolve_reasoning(
|
||||
agent: AgentConfig | None,
|
||||
provider: Provider,
|
||||
model: str | None,
|
||||
) -> ReasoningConfig:
|
||||
"""Merge ``[agent]`` < provider < model thinking settings for *model*.
|
||||
|
||||
*agent* is ``None`` for callers that only know the provider (config tools,
|
||||
tests); the model layer is the provider's entry for *model*, if any. Each
|
||||
field falls back on its own, so a model may inherit only the effort (or only
|
||||
the budget) from above.
|
||||
"""
|
||||
effective = resolve_effective_provider(provider, model=model)
|
||||
model_config = model_config_for(provider, model)
|
||||
|
||||
effort: ReasoningEffortConfig = ""
|
||||
for layer in (model_config, provider, agent):
|
||||
if layer is not None and layer.reasoning_effort:
|
||||
effort = layer.reasoning_effort
|
||||
break
|
||||
|
||||
budget = 0
|
||||
for layer in (model_config, provider, agent):
|
||||
if layer is not None and layer.thinking_budget_tokens > 0:
|
||||
budget = layer.thinking_budget_tokens
|
||||
break
|
||||
|
||||
return ReasoningConfig(
|
||||
effort=_clamp_effort(effort, preset=effective.preset, convention=effective.convention),
|
||||
thinking_budget_tokens=budget,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ReasoningConfig", "resolve_reasoning"]
|
||||
@@ -33,6 +33,8 @@ class EffectiveProvider:
|
||||
model: str | None
|
||||
timeout: float | HttpTimeoutConfig | None
|
||||
provider_tools: list[dict[str, object]]
|
||||
# Sources the local ``web_search`` tool tries, in fallback order.
|
||||
search_sources: list[str]
|
||||
source: Provider
|
||||
# DeepSeek API surface (see DeepSeekConvention): "openai" / "anthropic" /
|
||||
# "responses" for deepseek presets; "" (unused) for other presets.
|
||||
@@ -86,6 +88,39 @@ def model_config_for(provider: Provider, model: str | None) -> ModelConfig | Non
|
||||
return provider.models.get(model)
|
||||
|
||||
|
||||
def wants_web_search(effective: EffectiveProvider) -> bool:
|
||||
"""True when *effective*'s ``provider_tools`` ask for a search tool."""
|
||||
return any(
|
||||
isinstance(tool.get("type"), str) and tool.get("type") == "web_search" for tool in effective.provider_tools
|
||||
)
|
||||
|
||||
|
||||
def local_web_search(effective: EffectiveProvider) -> bool:
|
||||
"""True when this provider searches through plyngent's own ``web_search`` tool.
|
||||
|
||||
DeepSeek runs a hosted search only on its Anthropic-compatible surface
|
||||
(``convention = "anthropic"``): the Responses and chat-completions surfaces
|
||||
ignore a hosted tool — a live probe answers "I can't browse the web" with
|
||||
either ``{"type": "web_search"}`` or the server-tool shape — so a DeepSeek
|
||||
provider that talks them searches through
|
||||
:func:`~plyngent.tools.net.search.build_web_search_tool`, which asks the
|
||||
sources named by ``search_sources`` behind the scenes.
|
||||
"""
|
||||
if effective.preset != "deepseek" or effective.convention == "anthropic":
|
||||
return False
|
||||
if not effective.search_sources:
|
||||
return False
|
||||
return wants_web_search(effective)
|
||||
|
||||
|
||||
def deepseek_search_base_url(effective: EffectiveProvider) -> str:
|
||||
"""The endpoint a DeepSeek search turn runs on (its Anthropic-compatible base)."""
|
||||
url = effective.url.rstrip("/")
|
||||
if url.endswith("/anthropic"):
|
||||
return url
|
||||
return DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
|
||||
|
||||
|
||||
def resolve_effective_provider(provider: Provider, *, model: str | None = None) -> EffectiveProvider:
|
||||
"""Apply model-level preset/url overrides to *provider*.
|
||||
|
||||
@@ -116,9 +151,15 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None)
|
||||
else:
|
||||
url = default_url_for_preset(preset)
|
||||
|
||||
# Hosted tools and search sources belong to the preset that declares them:
|
||||
# OpenAI's Responses surface, and DeepSeek's (whose Anthropic surface runs the
|
||||
# search inside the response while its other surfaces get a local tool — see
|
||||
# :func:`local_web_search`).
|
||||
provider_tools: list[dict[str, object]] = []
|
||||
if preset == "openai" and isinstance(provider, OpenAIProvider) and parent_preset == "openai":
|
||||
search_sources: list[str] = []
|
||||
if parent_preset == preset and isinstance(provider, OpenAIProvider | DeepseekProvider):
|
||||
provider_tools = [dict(item) for item in provider.provider_tools]
|
||||
search_sources = [str(item) for item in provider.search_sources]
|
||||
|
||||
return EffectiveProvider(
|
||||
preset=preset,
|
||||
@@ -127,6 +168,7 @@ def resolve_effective_provider(provider: Provider, *, model: str | None = None)
|
||||
model=model,
|
||||
timeout=provider.timeout,
|
||||
provider_tools=provider_tools,
|
||||
search_sources=search_sources,
|
||||
source=provider,
|
||||
convention=convention,
|
||||
)
|
||||
|
||||
@@ -34,6 +34,8 @@ class AnthropicClient:
|
||||
decoder: msgspec.json.Decoder[AnthropicMessageResponse]
|
||||
stream_decoder: msgspec.json.Decoder[AnthropicStreamEvent]
|
||||
models_decoder: msgspec.json.Decoder[AnthropicModelsResponse]
|
||||
# Explicit ``thinking`` budget for this client (0 = derive from the effort).
|
||||
thinking_budget_tokens: int = 0
|
||||
_api_key: str
|
||||
_api_version: str
|
||||
|
||||
@@ -43,6 +45,7 @@ class AnthropicClient:
|
||||
self.decoder = msgspec.json.Decoder(AnthropicMessageResponse)
|
||||
self.stream_decoder = msgspec.json.Decoder(AnthropicStreamEvent)
|
||||
self.models_decoder = msgspec.json.Decoder(AnthropicModelsResponse)
|
||||
self.thinking_budget_tokens = max(0, config.thinking_budget_tokens)
|
||||
self._api_key = config.api_key
|
||||
self._api_version = config.anthropic_version
|
||||
|
||||
|
||||
@@ -20,3 +20,6 @@ class AnthropicConfig:
|
||||
base_url: str = DEFAULT_ANTHROPIC_BASE_URL
|
||||
anthropic_version: str = DEFAULT_ANTHROPIC_VERSION
|
||||
timeout: HttpTimeout = (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT)
|
||||
# Explicit ``thinking.budget_tokens`` for reasoning models (0 = derive it
|
||||
# from the configured reasoning_effort; see config/reasoning.py).
|
||||
thinking_budget_tokens: int = 0
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from msgspec import UNSET, Struct, field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plyngent.typedef import Unset
|
||||
from plyngent.typedef import Unset # noqa: TC001
|
||||
|
||||
|
||||
class AnthropicTextContent(Struct, tag_field="type", tag="text"):
|
||||
@@ -62,12 +61,30 @@ class AnthropicMetadata(Struct, omit_defaults=True):
|
||||
user_id: str | Unset = UNSET
|
||||
|
||||
|
||||
class AnthropicThinkingConfig(Struct, omit_defaults=True):
|
||||
"""``thinking`` request block (extended thinking).
|
||||
|
||||
``budget_tokens`` is the reasoning budget the model may spend before it
|
||||
answers; the API requires it to be at least 1024 and smaller than
|
||||
``max_tokens`` (see ``agent.messages_bridge``, which keeps both valid).
|
||||
|
||||
``type`` carries no default: ``omit_defaults`` would drop a defaulted one,
|
||||
and the server reads the block as malformed without it — DeepSeek answers
|
||||
``thinking: missing field ``type``.
|
||||
"""
|
||||
|
||||
type: Literal["enabled", "disabled"]
|
||||
budget_tokens: int | 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
|
||||
# ``dict`` entries are opaque hosted/server tools (e.g. DeepSeek's
|
||||
# ``{"type": "web_search_20250305", "name": "web_search"}``).
|
||||
tools: list[AnthropicToolDefinition | dict[str, Any]] | Unset = UNSET
|
||||
tool_choice: AnthropicToolChoice | Unset = UNSET
|
||||
metadata: AnthropicMetadata | Unset = UNSET
|
||||
stop_sequences: list[str] | Unset = UNSET
|
||||
@@ -75,6 +92,7 @@ class AnthropicMessagesParam(Struct, omit_defaults=True):
|
||||
temperature: float | Unset = UNSET
|
||||
top_p: float | Unset = UNSET
|
||||
top_k: int | Unset = UNSET
|
||||
thinking: AnthropicThinkingConfig | Unset = UNSET
|
||||
|
||||
|
||||
class AnthropicUsage(Struct, omit_defaults=True):
|
||||
@@ -94,7 +112,46 @@ class AnthropicResponseToolUse(Struct, tag_field="type", tag="tool_use"):
|
||||
input: dict[str, Any]
|
||||
|
||||
|
||||
type AnthropicResponseContent = AnthropicResponseText | AnthropicResponseToolUse
|
||||
class AnthropicThinkingContent(Struct, omit_defaults=True, tag_field="type", tag="thinking"):
|
||||
"""Extended-thinking block; ``signature`` is opaque and not verified back."""
|
||||
|
||||
thinking: str = ""
|
||||
signature: str | Unset = UNSET
|
||||
|
||||
|
||||
class AnthropicServerToolUseContent(Struct, omit_defaults=True, tag_field="type", tag="server_tool_use"):
|
||||
"""A tool the *server* runs — a web search the model asked its backend for.
|
||||
|
||||
Not a call for the local registry: the matching
|
||||
:class:`AnthropicWebSearchToolResultContent` arrives in the same response
|
||||
(see the ``messages_dispatch`` / ``messages_bridge`` bullets in AGENTS.md).
|
||||
"""
|
||||
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
input: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class AnthropicWebSearchToolResultContent(Struct, omit_defaults=True, tag_field="type", tag="web_search_tool_result"):
|
||||
"""What the server's search returned for one ``server_tool_use`` call.
|
||||
|
||||
``content`` stays loose: it holds ``web_search_result`` entries (title, url
|
||||
and an ``encrypted_content`` the API demands back unchanged — DeepSeek
|
||||
answers a trimmed result with 422) or a single
|
||||
``web_search_tool_result_error`` such as ``max_uses_exceeded``.
|
||||
"""
|
||||
|
||||
tool_use_id: str = ""
|
||||
content: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
type AnthropicResponseContent = (
|
||||
AnthropicResponseText
|
||||
| AnthropicResponseToolUse
|
||||
| AnthropicThinkingContent
|
||||
| AnthropicServerToolUseContent
|
||||
| AnthropicWebSearchToolResultContent
|
||||
)
|
||||
|
||||
|
||||
class AnthropicMessageResponse(Struct, omit_defaults=True):
|
||||
|
||||
@@ -17,8 +17,8 @@ class DeepseekAnthropicClient(AnthropicClient):
|
||||
(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.
|
||||
``claude-haiku*`` / ``claude-sonnet*`` → ``deepseek-flash``, unknown names →
|
||||
``deepseek-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.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Server-side web search on DeepSeek's Anthropic-compatible endpoint.
|
||||
|
||||
DeepSeek runs ``web_search`` *inside* a turn: the request carries the server
|
||||
tool, and the response comes back with a ``server_tool_use`` block per query
|
||||
plus the ``web_search_tool_result`` that query produced — nothing is executed
|
||||
here. Results only carry a title, a URL and an ``encrypted_content`` the index
|
||||
keeps the page body in, which is why the hit list is all a caller can pass on.
|
||||
|
||||
Only this surface searches: the Responses and chat-completions surfaces ignore a
|
||||
hosted search tool (a live probe answers "I can't browse the web" whether the
|
||||
tool arrives as ``{"type": "web_search"}`` or in the server-tool shape), so a
|
||||
DeepSeek provider that talks them searches through
|
||||
:func:`~plyngent.tools.net.search.build_web_search_tool` instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import msgspec
|
||||
import niquests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
||||
|
||||
# A search turn stops as soon as the search came back: the answer that follows is
|
||||
# thrown away, so the budget only has to cover the thinking that picks the query
|
||||
# and the results. A live probe with 64 tokens spent all of it before searching
|
||||
# and returned no results at all.
|
||||
DEFAULT_SEARCH_MAX_TOKENS = 512
|
||||
DEFAULT_SEARCH_MAX_USES = 3
|
||||
_HTTP_ERROR = 400
|
||||
_SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20250305", "name": "web_search"}
|
||||
|
||||
|
||||
class SearchHit(msgspec.Struct, frozen=True, omit_defaults=True):
|
||||
"""One page a search source matched.
|
||||
|
||||
DeepSeek's index keeps the page bodies server-side, so its hits carry a
|
||||
title and a URL only; a source that ships snippets (Bing's RSS) fills
|
||||
``snippet`` too.
|
||||
"""
|
||||
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
def hits_from_content(content: object) -> list[SearchHit]:
|
||||
"""Collect the search hits from a response's content blocks (deduplicated)."""
|
||||
if not isinstance(content, list):
|
||||
return []
|
||||
hits: list[SearchHit] = []
|
||||
seen: set[str] = set()
|
||||
for block_obj in cast("list[object]", content):
|
||||
if not isinstance(block_obj, dict):
|
||||
continue
|
||||
block = cast("dict[str, object]", block_obj)
|
||||
if block.get("type") != "web_search_tool_result":
|
||||
continue
|
||||
entries = block.get("content")
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry_obj in cast("list[object]", entries):
|
||||
if not isinstance(entry_obj, dict):
|
||||
continue
|
||||
entry = cast("dict[str, object]", entry_obj)
|
||||
if entry.get("type") != "web_search_result":
|
||||
continue
|
||||
url = entry.get("url")
|
||||
if not isinstance(url, str) or not url or url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
title = entry.get("title")
|
||||
hits.append(SearchHit(title=title if isinstance(title, str) else "", url=url))
|
||||
return hits
|
||||
|
||||
|
||||
class DeepseekSearchClient:
|
||||
"""``POST /messages`` with a hosted ``web_search`` tool, read for its hits."""
|
||||
|
||||
session: niquests.AsyncSession
|
||||
encoder: msgspec.json.Encoder
|
||||
_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._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 search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
model: str,
|
||||
max_uses: int = DEFAULT_SEARCH_MAX_USES,
|
||||
max_tokens: int = DEFAULT_SEARCH_MAX_TOKENS,
|
||||
) -> list[SearchHit]:
|
||||
"""Run *query* through the server's search; return the pages it matched."""
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [{"role": "user", "content": query}],
|
||||
"tools": [{**_SEARCH_TOOL, "max_uses": max_uses}],
|
||||
}
|
||||
resp = await self.session.post(
|
||||
"/messages",
|
||||
data=self.encoder.encode(payload),
|
||||
headers=self._headers(),
|
||||
stream=False,
|
||||
)
|
||||
body = await self._read_body(resp)
|
||||
parsed = msgspec.json.decode(body, type=dict[str, object])
|
||||
return hits_from_content(parsed.get("content"))
|
||||
|
||||
async def _read_body(self, resp: object) -> bytes:
|
||||
from plyngent.lmproto.openai_compatible.client import (
|
||||
http_error_message,
|
||||
read_response_body,
|
||||
)
|
||||
|
||||
body = await read_response_body(resp)
|
||||
status = int(getattr(resp, "status_code", 0) or 0)
|
||||
if status >= _HTTP_ERROR:
|
||||
detail = http_error_message(status, body, what="search") or f"search HTTP {status}"
|
||||
raise RuntimeError(detail)
|
||||
if body is None:
|
||||
msg = "search response body is empty"
|
||||
raise RuntimeError(msg)
|
||||
return body.encode() if isinstance(body, str) else bytes(body)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SEARCH_MAX_TOKENS",
|
||||
"DEFAULT_SEARCH_MAX_USES",
|
||||
"DeepseekSearchClient",
|
||||
"SearchHit",
|
||||
"hits_from_content",
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
from typing import TYPE_CHECKING, Literal, cast, overload
|
||||
|
||||
import msgspec
|
||||
|
||||
@@ -16,16 +16,43 @@ if TYPE_CHECKING:
|
||||
from .model import ChatCompletionsParam
|
||||
|
||||
|
||||
def _inject_thinking(data: bytes) -> bytes:
|
||||
"""Inject ``thinking: {type: "enabled"}`` into the encoded request body.
|
||||
def _inject_thinking(data: bytes, param: ChatCompletionsParam) -> bytes:
|
||||
"""Inject the ``thinking`` flag 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.
|
||||
explicitly set (default is ``enabled``); ``reasoning_effort = "none"`` is the
|
||||
way to ask for it off. 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"}
|
||||
body["thinking"] = {"type": "disabled" if param.reasoning_effort == "none" else "enabled"}
|
||||
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _inject_missing_reasoning_content(data: bytes) -> bytes:
|
||||
"""Give every assistant message a ``reasoning_content`` field.
|
||||
|
||||
Thinking mode rejects an assistant round whose message carries no reasoning
|
||||
("the ``reasoning_content`` … must be passed back to the API") — for a tool
|
||||
call and for a plain answer alike. History can hold such a round: a
|
||||
rolled-back turn keeps the calls that ran as a bare pairing carrier without
|
||||
it (see ``rollback_tail``), a synthetic ``todo_list`` nag is forged the same
|
||||
way, and a compacted session starts with a seed summary carrying none. The
|
||||
field only has to be there, so an empty string satisfies the check (unlike
|
||||
the Responses API, whose ``reasoning`` item needs non-empty text — see
|
||||
``agent.responses_bridge``).
|
||||
"""
|
||||
body = cast("dict[str, object]", json.loads(data))
|
||||
messages = body.get("messages")
|
||||
if isinstance(messages, list):
|
||||
for message in cast("list[object]", messages):
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
item = cast("dict[str, object]", message)
|
||||
if item.get("role") != "assistant" or "reasoning_content" in item:
|
||||
continue
|
||||
item["reasoning_content"] = ""
|
||||
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
@@ -49,7 +76,8 @@ class DeepseekOpenAIClient(BaseOpenAIClient):
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
param = coerce_chat_completions_param_any(msgspec.structs.replace(param, stream=stream))
|
||||
data = _inject_thinking(self.encoder.encode(param))
|
||||
data = _inject_thinking(self.encoder.encode(param), param)
|
||||
data = _inject_missing_reasoning_content(data)
|
||||
if stream:
|
||||
resp = await self.session.post(
|
||||
"/chat/completions",
|
||||
|
||||
@@ -232,24 +232,24 @@ class Response(Struct, omit_defaults=True):
|
||||
output: list[dict[str, Any]]
|
||||
object: Literal["response"] = "response"
|
||||
status: ResponseStatus | Unset = UNSET
|
||||
error: ResponseError | None | Unset = UNSET
|
||||
incomplete_details: ResponseIncompleteDetails | None | Unset = UNSET
|
||||
instructions: str | list[dict[str, Any]] | None | Unset = UNSET
|
||||
metadata: dict[str, str] | None | Unset = UNSET
|
||||
error: ResponseError | Unset | None = UNSET
|
||||
incomplete_details: ResponseIncompleteDetails | Unset | None = UNSET
|
||||
instructions: str | list[dict[str, Any]] | Unset | None = UNSET
|
||||
metadata: dict[str, str] | Unset | None = UNSET
|
||||
parallel_tool_calls: bool | Unset = UNSET
|
||||
temperature: float | None | Unset = UNSET
|
||||
top_p: float | None | Unset = UNSET
|
||||
temperature: float | Unset | None = UNSET
|
||||
top_p: float | Unset | None = UNSET
|
||||
tools: list[dict[str, Any]] | Unset = UNSET
|
||||
tool_choice: str | dict[str, Any] | Unset = UNSET
|
||||
# Prefer loose dict — nested usage details vary by model/API version.
|
||||
usage: dict[str, Any] | None | Unset = UNSET
|
||||
previous_response_id: str | None | Unset = UNSET
|
||||
service_tier: ServiceTier | None | Unset = UNSET
|
||||
truncation: TruncationMode | None | Unset = UNSET
|
||||
usage: dict[str, Any] | Unset | None = UNSET
|
||||
previous_response_id: str | Unset | None = UNSET
|
||||
service_tier: ServiceTier | Unset | None = UNSET
|
||||
truncation: TruncationMode | Unset | None = UNSET
|
||||
text: dict[str, Any] | Unset = UNSET
|
||||
reasoning: dict[str, Any] | None | Unset = UNSET
|
||||
reasoning: dict[str, Any] | Unset | None = UNSET
|
||||
store: bool | Unset = UNSET
|
||||
user: str | None | Unset = UNSET
|
||||
user: str | Unset | None = UNSET
|
||||
|
||||
|
||||
class ResponseDeleted(Struct):
|
||||
|
||||
@@ -59,7 +59,7 @@ async def read_response_body(resp: object) -> bytes | str | None:
|
||||
return None
|
||||
|
||||
|
||||
def sse_data_payload(line: bytes) -> bytes | None | Literal[False]:
|
||||
def sse_data_payload(line: bytes) -> bytes | Literal[False] | None:
|
||||
"""Parse one SSE line.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -67,7 +67,7 @@ type AnyAssistantToolCall = AssistantFunctionToolCall | AssistantCustomToolCall
|
||||
|
||||
class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
|
||||
# APIs often return content=null when tool_calls are present.
|
||||
content: str | None | Unset = UNSET
|
||||
content: str | Unset | None = UNSET
|
||||
name: str | Unset = UNSET
|
||||
audio: IDObject | Unset = UNSET
|
||||
refusal: str | Unset = UNSET
|
||||
@@ -237,8 +237,8 @@ class StreamToolCallDelta(Struct):
|
||||
class DeltaMessage(Struct):
|
||||
role: RoleAssistant | Unset = UNSET
|
||||
# Providers often send content=null / reasoning_content=null on partial chunks.
|
||||
content: str | None | Unset = UNSET
|
||||
reasoning_content: str | None | Unset = UNSET
|
||||
content: str | Unset | None = UNSET
|
||||
reasoning_content: str | Unset | None = UNSET
|
||||
# True when reasoning_content is the FULL chain-of-thought (e.g. DeepSeek's
|
||||
# ``response.reasoning_text.done``), replacing any streamed fragments.
|
||||
reasoning_full: bool | Unset = UNSET
|
||||
@@ -249,8 +249,8 @@ class ChunkChoice(Struct):
|
||||
index: int
|
||||
delta: DeltaMessage
|
||||
# APIs often send logprobs=null on stream choices.
|
||||
logprobs: dict[str, Any] | None | Unset = UNSET
|
||||
finish_reason: FinishReason | None | Unset = UNSET
|
||||
logprobs: dict[str, Any] | Unset | None = UNSET
|
||||
finish_reason: FinishReason | Unset | None = UNSET
|
||||
|
||||
|
||||
class ChatCompletionChunk(Struct):
|
||||
@@ -260,7 +260,7 @@ class ChatCompletionChunk(Struct):
|
||||
model: str
|
||||
choices: list[ChunkChoice]
|
||||
# Final usage chunk may omit or null usage depending on provider.
|
||||
usage: dict[str, Any] | None | Unset = UNSET
|
||||
usage: dict[str, Any] | Unset | None = UNSET
|
||||
|
||||
|
||||
class ModelObject(Struct):
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.config.reasoning import ReasoningConfig, resolve_reasoning
|
||||
from plyngent.config.routing import EffectiveProvider, resolve_effective_provider
|
||||
from plyngent.lmproto.anthropic import AnthropicClient
|
||||
from plyngent.lmproto.anthropic.config import AnthropicConfig as AnthropicConfigCls
|
||||
@@ -103,7 +104,7 @@ def provider_to_openai_config(
|
||||
)
|
||||
|
||||
|
||||
def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient:
|
||||
def _anthropic_client(effective: EffectiveProvider, reasoning: ReasoningConfig) -> AnthropicClient:
|
||||
"""Build an Anthropic Messages client (real or DeepSeek convention) for *effective*."""
|
||||
try:
|
||||
http_timeout = normalize_http_timeout(effective.timeout)
|
||||
@@ -115,6 +116,7 @@ def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient:
|
||||
api_key=effective.access_key_or_token,
|
||||
base_url=effective.url or DEFAULT_DEEPSEEK_BASE_URL,
|
||||
timeout=http_timeout,
|
||||
thinking_budget_tokens=reasoning.thinking_budget_tokens,
|
||||
)
|
||||
)
|
||||
return AnthropicClient(
|
||||
@@ -122,11 +124,17 @@ def _anthropic_client(effective: EffectiveProvider) -> AnthropicClient:
|
||||
api_key=effective.access_key_or_token,
|
||||
base_url=effective.url or DEFAULT_ANTHROPIC_BASE_URL,
|
||||
timeout=http_timeout,
|
||||
thinking_budget_tokens=reasoning.thinking_budget_tokens,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_client(provider: Provider, *, model: str | None = None) -> ProtocolClient:
|
||||
def create_client(
|
||||
provider: Provider,
|
||||
*,
|
||||
model: str | None = None,
|
||||
reasoning: ReasoningConfig | None = None,
|
||||
) -> ProtocolClient:
|
||||
"""Build a protocol client for *provider*, applying model-level routing.
|
||||
|
||||
``preset`` always decides API conventions: ``openai`` → /responses,
|
||||
@@ -134,8 +142,15 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl
|
||||
Exception: a ``deepseek`` preset picks the Responses or Anthropic surface
|
||||
when its effective ``convention`` is ``"responses"`` / ``"anthropic"``,
|
||||
else chat completions.
|
||||
|
||||
*reasoning* is the merged thinking config for this provider + model (see
|
||||
:func:`plyngent.config.reasoning.resolve_reasoning`); callers without an
|
||||
``[agent]`` layer may omit it and get the provider/model layers only. It only
|
||||
reaches surfaces that cannot read the setting off the request param —
|
||||
Anthropic needs a token budget, while chat/Responses carry an effort level.
|
||||
"""
|
||||
effective = resolve_effective_provider(provider, model=model)
|
||||
spec = reasoning if reasoning is not None else resolve_reasoning(None, provider, model)
|
||||
if effective.preset == "openai":
|
||||
return OpenAIClient(provider_to_openai_config(effective))
|
||||
if effective.preset == "openai-compatible":
|
||||
@@ -144,9 +159,9 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl
|
||||
if effective.convention == "responses":
|
||||
return DeepseekResponsesClient(provider_to_openai_config(effective))
|
||||
if effective.convention == "anthropic":
|
||||
return _anthropic_client(effective)
|
||||
return _anthropic_client(effective, spec)
|
||||
return DeepseekOpenAIClient(provider_to_openai_config(effective))
|
||||
if effective.preset == "anthropic":
|
||||
return _anthropic_client(effective)
|
||||
return _anthropic_client(effective, spec)
|
||||
msg = f"provider preset {effective.preset!r} is not supported"
|
||||
raise ProviderNotSupportedError(msg)
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
__all__ = ["UnwrappedCommand", "basename", "is_interpreter", "unwrap_command"]
|
||||
__all__ = ["CommandString", "UnwrappedCommand", "basename", "is_interpreter", "unwrap_command"]
|
||||
|
||||
# Wrappers that run the program named on their own command line.
|
||||
_ENV_WRAPPERS: frozenset[str] = frozenset(
|
||||
@@ -107,17 +107,20 @@ _DURATION = re.compile(r"[0-9]+(?:\.[0-9]+)?[smhd]?")
|
||||
|
||||
# Options that consume the next token, so ``sudo -u root python`` still finds
|
||||
# ``python``; jammed forms (``-n10``, ``--with=pkg``) need no entry, and unknown
|
||||
# options are skipped bare.
|
||||
# options are skipped bare. List value-taking options only: an entry for a flag
|
||||
# eats the program behind it, which hid ``rm`` from ``exec -c rm -rf /`` and
|
||||
# judged ``exec -c git -c … init`` as ``init``. An option with an *optional*
|
||||
# value (``xargs -e``, ``watch -d``) needs no entry either — its bare form runs
|
||||
# the program, and the jammed form carries the value in the same token.
|
||||
_VALUE_OPTS: dict[str, frozenset[str]] = {
|
||||
"command": frozenset({"-p", "-v", "-V"}),
|
||||
"doas": frozenset({"-u", "--user"}),
|
||||
"env": frozenset({"-C", "-S", "-u", "--chdir", "--split-string", "--unset"}),
|
||||
"exec": frozenset({"-a", "-c", "-l"}),
|
||||
"exec": frozenset({"-a"}),
|
||||
"ionice": frozenset({"-c", "-n", "-p", "-P", "-u"}),
|
||||
"nice": frozenset({"-n", "--adjustment"}),
|
||||
"pkexec": frozenset({"--disable-internal-agent", "--user"}),
|
||||
"pkexec": frozenset({"--user"}),
|
||||
"stdbuf": frozenset({"-e", "-i", "-o"}),
|
||||
"su": frozenset({"-c", "-g", "-G", "-p", "-s", "-u", "-w", "--command", "--group", "--session-command", "--shell"}),
|
||||
"su": frozenset({"-c", "-g", "-G", "-s", "-u", "-w", "--command", "--group", "--session-command", "--shell"}),
|
||||
"sudo": frozenset(
|
||||
{
|
||||
"-C",
|
||||
@@ -145,7 +148,17 @@ _VALUE_OPTS: dict[str, frozenset[str]] = {
|
||||
"time": frozenset({"-f", "-o", "--format", "--output"}),
|
||||
"timeout": frozenset({"-k", "-s", "--kill-after", "--signal"}),
|
||||
"watch": frozenset({"-n"}),
|
||||
"xargs": frozenset({"-a", "-d", "-E", "-e", "-I", "-L", "-n", "-P", "-s"}),
|
||||
"xargs": frozenset({"-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s"}),
|
||||
}
|
||||
|
||||
# Options whose value is a *command line* the wrapper will run, so the value is
|
||||
# code rather than an argument: ``env -S "rm -rf /"`` and ``su -c "…"``. An
|
||||
# interpreter's own ``-c`` is the same idea, but there the interpreter is the
|
||||
# program under review; here a wrapper hides the command, so the review has to
|
||||
# surface it (:func:`plyngent.tools.danger.classify_danger`).
|
||||
_COMMAND_STRING_OPTS: dict[str, frozenset[str]] = {
|
||||
"env": frozenset({"-S", "--split-string"}),
|
||||
"su": frozenset({"-c", "--command", "--session-command"}),
|
||||
}
|
||||
|
||||
# Launcher options that consume a value (``uv run --with rich python``).
|
||||
@@ -186,6 +199,20 @@ _RUN_VALUE_OPTS: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandString:
|
||||
"""A command line a wrapper took as an option *value* (``env -S …``)."""
|
||||
|
||||
wrapper: str
|
||||
option: str
|
||||
text: str
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""``env -S`` — how the option is named in review output."""
|
||||
return f"{self.wrapper} {self.option}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnwrappedCommand:
|
||||
"""A command plus the program it really runs, past wrappers and launchers."""
|
||||
@@ -195,6 +222,7 @@ class UnwrappedCommand:
|
||||
wrappers: tuple[str, ...]
|
||||
base: str
|
||||
self_review: tuple[str, ...]
|
||||
command_strings: tuple[CommandString, ...] = ()
|
||||
|
||||
@property
|
||||
def command(self) -> tuple[str, ...]:
|
||||
@@ -221,14 +249,16 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
|
||||
"""Resolve *argv* to the program behind any wrappers and ``run`` launchers.
|
||||
|
||||
Wrappers are unwrapped outer → inner as long as a program still follows
|
||||
them; their own options and ``FOO=bar`` assignments are skipped.
|
||||
them; their own options and ``FOO=bar`` assignments are skipped, and an
|
||||
option that carries a command line is collected in ``command_strings``.
|
||||
"""
|
||||
parts = list(argv)
|
||||
wrappers: list[str] = []
|
||||
command_strings: list[CommandString] = []
|
||||
index = 0
|
||||
while index < len(parts):
|
||||
name = basename(parts[index])
|
||||
nxt = _skip_wrapper(name, parts, index + 1)
|
||||
nxt = _skip_wrapper(name, parts, index + 1, command_strings=command_strings)
|
||||
if nxt is None:
|
||||
break
|
||||
wrappers.append(name)
|
||||
@@ -241,31 +271,60 @@ def unwrap_command(argv: Sequence[str]) -> UnwrappedCommand:
|
||||
wrappers=tuple(wrappers),
|
||||
base=base,
|
||||
self_review=tuple(name for name in chain if name in _SELF_REVIEW),
|
||||
command_strings=tuple(command_strings),
|
||||
)
|
||||
|
||||
|
||||
def _skip_wrapper(name: str, parts: list[str], start: int) -> int | None:
|
||||
def _skip_wrapper(
|
||||
name: str,
|
||||
parts: list[str],
|
||||
start: int,
|
||||
*,
|
||||
command_strings: list[CommandString],
|
||||
) -> int | None:
|
||||
"""Index of the program *name* wraps, or ``None`` when it is the whole command."""
|
||||
if name in _ENV_WRAPPERS:
|
||||
nxt = _skip_options(
|
||||
parts,
|
||||
start,
|
||||
name=name,
|
||||
value_opts=_VALUE_OPTS.get(name, frozenset()),
|
||||
leading_duration=name in _DURATION_WRAPPERS,
|
||||
command_strings=command_strings,
|
||||
)
|
||||
return nxt if nxt < len(parts) else None
|
||||
if name in _RUN_LAUNCHERS and start < len(parts) and parts[start] == "run":
|
||||
nxt = _skip_options(parts, start + 1, value_opts=_RUN_VALUE_OPTS, leading_duration=False)
|
||||
nxt = _skip_options(
|
||||
parts,
|
||||
start + 1,
|
||||
name=name,
|
||||
value_opts=_RUN_VALUE_OPTS,
|
||||
leading_duration=False,
|
||||
command_strings=command_strings,
|
||||
)
|
||||
return nxt if nxt < len(parts) else None
|
||||
return None
|
||||
|
||||
|
||||
def _record_command_string(
|
||||
name: str,
|
||||
option: str,
|
||||
text: str,
|
||||
command_strings: list[CommandString],
|
||||
) -> None:
|
||||
"""Note *text* when *option* hands a command line to wrapper *name*."""
|
||||
if option in _COMMAND_STRING_OPTS.get(name, frozenset()):
|
||||
command_strings.append(CommandString(wrapper=name, option=option, text=text))
|
||||
|
||||
|
||||
def _skip_options(
|
||||
parts: list[str],
|
||||
start: int,
|
||||
*,
|
||||
name: str,
|
||||
value_opts: frozenset[str],
|
||||
leading_duration: bool,
|
||||
command_strings: list[CommandString],
|
||||
) -> int:
|
||||
"""Index of the first token after a wrapper's own options and assignments."""
|
||||
index = start
|
||||
@@ -280,7 +339,17 @@ def _skip_options(
|
||||
if token.startswith("-"):
|
||||
index += 1
|
||||
if token in value_opts:
|
||||
value = parts[index] if index < len(parts) else ""
|
||||
_record_command_string(name, token, value, command_strings)
|
||||
index += 1
|
||||
continue
|
||||
# Jammed forms: ``--split-string=…`` and ``-S…`` carry the value in
|
||||
# the token itself, so a command line may hide there too.
|
||||
option, equal, attached = token.partition("=")
|
||||
if equal:
|
||||
_record_command_string(name, option, attached, command_strings)
|
||||
elif token[:2] in _COMMAND_STRING_OPTS.get(name, frozenset()):
|
||||
_record_command_string(name, token[:2], token[2:], command_strings)
|
||||
continue
|
||||
if leading_duration and not took_duration and _DURATION.fullmatch(token):
|
||||
took_duration = True
|
||||
|
||||
@@ -61,13 +61,18 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
|
||||
forms (``env FOO=1 python …``, ``nohup pdm run python …``), and an
|
||||
interpreter is confirmed the same way however it runs — a bare shell, a
|
||||
script (``python x.py``), or a ``-c`` one-liner, whose code is printed below
|
||||
``command:`` instead of inline.
|
||||
``command:`` instead of inline. A wrapper option that takes a command line
|
||||
(``env -S "…"``, ``su -c "…"``) hides code the same way, so it is confirmed
|
||||
too, with the text it carries printed under its own ``<wrapper> <option>:``
|
||||
label.
|
||||
"""
|
||||
scan = unwrap_command(argv)
|
||||
interpreter = is_interpreter(scan.base)
|
||||
findings = [f"{name} (detached run)" for name in scan.self_review]
|
||||
if interpreter:
|
||||
findings.append(f"interpreter {scan.base!r}")
|
||||
findings.extend(f"{span.label} runs a command line" for span in scan.command_strings)
|
||||
findings = list(dict.fromkeys(findings))
|
||||
if not findings:
|
||||
return None
|
||||
code = _dash_c_code(scan.command) if interpreter else None
|
||||
@@ -76,6 +81,8 @@ def _command_reason(argv: Sequence[str], *, via: str) -> str | None:
|
||||
reason = f"{via}: {' + '.join(findings)} — review before allow\n argv: {display}"
|
||||
if code is not None:
|
||||
reason += f"\n command:\n{_indent_block(code[1])}"
|
||||
for span in scan.command_strings:
|
||||
reason += f"\n {span.label}:\n{_indent_block(span.text)}"
|
||||
return reason
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Bing's RSS endpoint as a search source for the local ``web_search`` tool.
|
||||
|
||||
Bing answers a query with a plain RSS document (``?format=rss``): no key, no
|
||||
page scraping, and each ``<item>`` already carries a title, a link and a
|
||||
description (the snippet DeepSeek's own index does not hand out). The request
|
||||
goes through the fetch client the ``fetch`` tool uses, so the SSRF and
|
||||
private-host grant policy applies to it as well.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
|
||||
from plyngent.tools.net.client import http_fetch
|
||||
|
||||
SEARCH_URL = "https://www.bing.com/search"
|
||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
DEFAULT_MAX_BYTES = 2_000_000
|
||||
_HTTP_ERROR = 400
|
||||
|
||||
|
||||
def parse_bing_rss(document: str) -> list[SearchHit]:
|
||||
"""Map Bing's RSS items to hits (title, link, description as the snippet)."""
|
||||
try:
|
||||
root = ET.fromstring(document)
|
||||
except ET.ParseError as exc:
|
||||
msg = f"bing search returned unparseable XML: {exc}"
|
||||
raise RuntimeError(msg) from exc
|
||||
hits: list[SearchHit] = []
|
||||
for item in root.iter("item"):
|
||||
title = (item.findtext("title") or "").strip()
|
||||
url = (item.findtext("link") or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
hits.append(SearchHit(title=title, url=url, snippet=(item.findtext("description") or "").strip()))
|
||||
return hits
|
||||
|
||||
|
||||
class BingSearchBackend:
|
||||
"""Search through Bing's RSS results (no key, snippets included)."""
|
||||
|
||||
timeout_seconds: float
|
||||
|
||||
def __init__(self, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
async def search(self, query: str) -> list[SearchHit]:
|
||||
"""Run *query* against Bing and return the items it answered with."""
|
||||
query_string = urllib.parse.urlencode({"format": "rss", "q": query})
|
||||
result = await http_fetch(
|
||||
method="GET",
|
||||
url=f"{SEARCH_URL}?{query_string}",
|
||||
headers={},
|
||||
body=None,
|
||||
timeout_seconds=self.timeout_seconds,
|
||||
max_bytes=DEFAULT_MAX_BYTES,
|
||||
)
|
||||
if result.status >= _HTTP_ERROR:
|
||||
msg = f"bing search HTTP {result.status}"
|
||||
raise RuntimeError(msg)
|
||||
return parse_bing_rss(result.body_text)
|
||||
|
||||
|
||||
__all__ = ["SEARCH_URL", "BingSearchBackend", "parse_bing_rss"]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""``web_search``: the model's way to search when its surface cannot host one.
|
||||
|
||||
DeepSeek only searches on its Anthropic-compatible surface, so a DeepSeek
|
||||
provider that talks the Responses or chat-completions surface gets this local
|
||||
tool instead of a hosted one (see ``config.routing.local_web_search``): the
|
||||
model calls it like any other tool and the handler asks the configured search
|
||||
sources behind the scenes — ``provider.search_sources``, in fallback order, so
|
||||
the next source is tried when one fails or finds nothing. A hit is a title, a
|
||||
URL and (when the source has one) a snippet: read a page with ``fetch`` before
|
||||
relying on its content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from plyngent.lmproto.deepseek.anthropic.search import (
|
||||
DeepseekSearchClient,
|
||||
SearchHit,
|
||||
)
|
||||
|
||||
WEB_SEARCH_TOOL_NAME = "web_search"
|
||||
DEFAULT_MAX_HITS = 8
|
||||
|
||||
|
||||
class SearchBackend(Protocol):
|
||||
"""One way to answer a query (``provider.search_sources`` picks the order)."""
|
||||
|
||||
async def search(self, query: str) -> Sequence[SearchHit]: ...
|
||||
|
||||
|
||||
class DeepseekSearchBackend:
|
||||
"""DeepSeek's own index: one server-side search turn per query."""
|
||||
|
||||
_client: DeepseekSearchClient
|
||||
_model: str
|
||||
|
||||
def __init__(self, client: DeepseekSearchClient, *, model: str) -> None:
|
||||
self._client = client
|
||||
self._model = model
|
||||
|
||||
async def search(self, query: str) -> Sequence[SearchHit]:
|
||||
return await self._client.search(query, model=self._model)
|
||||
|
||||
|
||||
async def search_in_order(
|
||||
backends: Sequence[SearchBackend],
|
||||
query: str,
|
||||
) -> tuple[list[SearchHit], list[str]]:
|
||||
"""Ask each backend in turn; the first one that answers wins.
|
||||
|
||||
Returns ``(hits, failures)`` — a source that raises or finds nothing is
|
||||
recorded and the next one gets its chance, so a broken or rate-limited
|
||||
source never takes the tool down with it.
|
||||
"""
|
||||
failures: list[str] = []
|
||||
for backend in backends:
|
||||
name = type(backend).__name__
|
||||
try:
|
||||
hits = list(await backend.search(query))
|
||||
except Exception as exc: # noqa: BLE001 — a failing source falls through
|
||||
failures.append(f"{name}: {exc}")
|
||||
continue
|
||||
if hits:
|
||||
return hits, failures
|
||||
failures.append(f"{name}: no results")
|
||||
return [], failures
|
||||
|
||||
|
||||
def build_web_search_tool(
|
||||
backends: Sequence[SearchBackend],
|
||||
*,
|
||||
max_hits: int = DEFAULT_MAX_HITS,
|
||||
) -> ToolDefinition:
|
||||
"""A ``web_search`` definition over *backends*, tried in order."""
|
||||
|
||||
async def web_search(query: str, hits: int = max_hits) -> str:
|
||||
"""Search the web and return the pages that matched.
|
||||
|
||||
One entry per hit (``title — url``, plus the snippet when the source has
|
||||
one): read a page with ``fetch`` before relying on its content.
|
||||
"""
|
||||
found, failures = await search_in_order(backends, query)
|
||||
if not found:
|
||||
detail = "; ".join(failures) if failures else "no source configured"
|
||||
return f"error: the search found nothing ({detail})"
|
||||
lines: list[str] = []
|
||||
for hit in found[:hits]:
|
||||
lines.append(f"- {hit.title or '(untitled)'} — {hit.url}")
|
||||
if hit.snippet:
|
||||
lines.append(f" {hit.snippet}")
|
||||
return "\n".join(lines)
|
||||
|
||||
return ToolDefinition(
|
||||
name=WEB_SEARCH_TOOL_NAME,
|
||||
description=inspect.getdoc(web_search) or "",
|
||||
parameters=schema_from_callable(web_search),
|
||||
handler=web_search,
|
||||
tags=ToolTag.LOCAL | ToolTag.READ_ONLY,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_HITS",
|
||||
"WEB_SEARCH_TOOL_NAME",
|
||||
"DeepseekSearchBackend",
|
||||
"SearchBackend",
|
||||
"build_web_search_tool",
|
||||
"search_in_order",
|
||||
]
|
||||
@@ -211,6 +211,20 @@ async def test_run_chat_loop_text_only() -> None:
|
||||
assert len(client.calls) == 1
|
||||
|
||||
|
||||
async def test_run_chat_loop_carries_reasoning_effort() -> None:
|
||||
client = ScriptedClient([_response(AssistantChatMessage(content="hello"))])
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False, reasoning_effort="high")]
|
||||
assert client.calls[0].reasoning_effort == "high"
|
||||
|
||||
|
||||
async def test_run_chat_loop_omits_unset_reasoning_effort() -> None:
|
||||
client = ScriptedClient([_response(AssistantChatMessage(content="hello"))])
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
assert client.calls[0].reasoning_effort is UNSET
|
||||
|
||||
|
||||
async def test_non_stream_emits_usage() -> None:
|
||||
client = ScriptedClient(
|
||||
[
|
||||
@@ -639,6 +653,121 @@ async def test_chat_agent_failed_turn_keeps_user_in_db() -> None:
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_rollback_keeps_executed_tool_result_in_store() -> None:
|
||||
"""Rolling back a cut-short batch keeps its executed result, in memory and on disk."""
|
||||
store = await MemoryStore.open(DatabaseConfig())
|
||||
session = await store.create_session(name="t")
|
||||
ran: list[str] = []
|
||||
|
||||
@tool(register=False)
|
||||
def first() -> str:
|
||||
ran.append("first")
|
||||
return "first ran"
|
||||
|
||||
client = ScriptedClient([_response(AssistantChatMessage(content="finished"))])
|
||||
agent = ChatAgent(
|
||||
client,
|
||||
model="m",
|
||||
tools=ToolRegistry([first]),
|
||||
memory=store,
|
||||
session_id=session.sid,
|
||||
stream=False,
|
||||
)
|
||||
user = UserChatMessage(content="do both")
|
||||
await store.append_message(session.sid, user)
|
||||
agent.replace_messages(
|
||||
[
|
||||
user,
|
||||
AssistantChatMessage(
|
||||
content="running both",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_1", function=AssistantFunctionTool(name="first", arguments="{}")
|
||||
),
|
||||
AssistantFunctionToolCall(
|
||||
id="call_2", function=AssistantFunctionTool(name="other", arguments="{}")
|
||||
),
|
||||
],
|
||||
),
|
||||
ToolChatMessage(tool_call_id="call_1", content="first ran"),
|
||||
],
|
||||
persist_from=1,
|
||||
)
|
||||
|
||||
await agent._rollback_uncommitted(0) # the failure path itself
|
||||
|
||||
kept = [message for message in agent.messages if isinstance(message, ToolChatMessage)]
|
||||
assert [message.content for message in kept] == ["first ran"]
|
||||
assert agent.pending_retry_text == "do both"
|
||||
# What the model continues from is on disk too, or a restart re-runs it.
|
||||
assert agent.persist_from == len(agent.messages)
|
||||
|
||||
stored = await store.list_messages(session.sid)
|
||||
assert [type(message).__name__ for message in stored] == [
|
||||
"UserChatMessage",
|
||||
"AssistantChatMessage",
|
||||
"ToolChatMessage",
|
||||
]
|
||||
record = stored[1]
|
||||
assert isinstance(record, AssistantChatMessage)
|
||||
assert record.content is UNSET
|
||||
calls = record.tool_calls
|
||||
assert calls is not UNSET
|
||||
assert [call.id for call in calls] == ["call_1"]
|
||||
|
||||
# The retry answers from the stored result instead of running the tool again.
|
||||
events = [event async for event in agent.retry()]
|
||||
assert ran == []
|
||||
assert any(isinstance(event, TextDeltaEvent) and event.content == "finished" for event in events)
|
||||
sent = client.calls[-1].messages
|
||||
assert any(isinstance(message, ToolChatMessage) and message.content == "first ran" for message in sent)
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_cancelled_batch_records_every_computed_result() -> None:
|
||||
"""Leaving mid-batch keeps the results that were already computed."""
|
||||
ran: list[str] = []
|
||||
|
||||
@tool(register=False)
|
||||
def first() -> str:
|
||||
ran.append("first")
|
||||
return "first ran"
|
||||
|
||||
@tool(register=False)
|
||||
def second() -> str:
|
||||
ran.append("second")
|
||||
return "second ran"
|
||||
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(
|
||||
AssistantChatMessage(
|
||||
content="running both",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_1", function=AssistantFunctionTool(name="first", arguments="{}")
|
||||
),
|
||||
AssistantFunctionToolCall(
|
||||
id="call_2", function=AssistantFunctionTool(name="second", arguments="{}")
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
agent = ChatAgent(client, model="m", tools=ToolRegistry([first, second]), stream=False)
|
||||
|
||||
stream = agent.run("do both")
|
||||
async for event in stream:
|
||||
if isinstance(event, ToolResultEvent):
|
||||
break
|
||||
await stream.aclose()
|
||||
|
||||
results = [message for message in agent.messages if isinstance(message, ToolChatMessage)]
|
||||
assert [message.content for message in results] == ["first ran", "second ran"]
|
||||
assert sorted(ran) == ["first", "second"]
|
||||
|
||||
|
||||
async def test_chat_agent_retry_after_failure() -> None:
|
||||
store = await MemoryStore.open(DatabaseConfig())
|
||||
session = await store.create_session(name="t")
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.agent.messages_bridge import (
|
||||
@@ -11,6 +13,7 @@ from plyngent.agent.messages_bridge import (
|
||||
anthropic_usage_to_dict,
|
||||
chat_messages_to_anthropic,
|
||||
chat_param_to_anthropic_param,
|
||||
provider_tools_to_anthropic_tools,
|
||||
tool_items_to_anthropic_tools,
|
||||
)
|
||||
from plyngent.lmproto.anthropic.model import (
|
||||
@@ -18,10 +21,14 @@ from plyngent.lmproto.anthropic.model import (
|
||||
AnthropicMessageResponse,
|
||||
AnthropicResponseText,
|
||||
AnthropicResponseToolUse,
|
||||
AnthropicServerToolUseContent,
|
||||
AnthropicThinkingContent,
|
||||
AnthropicToolDefinition,
|
||||
AnthropicToolResultContent,
|
||||
AnthropicToolUseContent,
|
||||
AnthropicUsage,
|
||||
AnthropicUserMessage,
|
||||
AnthropicWebSearchToolResultContent,
|
||||
)
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
@@ -29,6 +36,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantFunctionToolCall,
|
||||
ChatCompletionsParam,
|
||||
DeveloperChatMessage,
|
||||
ReasoningEffort,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
ToolFunction,
|
||||
@@ -36,6 +44,9 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from plyngent.typedef import Unset
|
||||
|
||||
|
||||
def test_tool_items_to_anthropic_tools() -> None:
|
||||
items = [
|
||||
@@ -145,6 +156,69 @@ def test_anthropic_response_to_assistant_with_tools() -> None:
|
||||
assert '"a":1' in call.function.arguments or '"a": 1' in call.function.arguments
|
||||
|
||||
|
||||
def test_anthropic_response_to_assistant_ignores_server_tool_blocks() -> None:
|
||||
"""A server-side search is not a call for the registry, nor part of the message."""
|
||||
response = AnthropicMessageResponse(
|
||||
id="msg_search",
|
||||
model="deepseek-flash",
|
||||
stop_reason="end_turn",
|
||||
content=[
|
||||
AnthropicThinkingContent(thinking="Let me search.", signature="sig-1"),
|
||||
AnthropicServerToolUseContent(id="call_00_1", name="web_search", input={"query": "DeepSeek-V4"}),
|
||||
AnthropicWebSearchToolResultContent(
|
||||
tool_use_id="call_00_1",
|
||||
content=[{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
|
||||
),
|
||||
AnthropicResponseText(text="DeepSeek-V4 is out."),
|
||||
],
|
||||
usage=AnthropicUsage(input_tokens=16592, output_tokens=1419),
|
||||
)
|
||||
assistant = anthropic_response_to_assistant(response)
|
||||
assert assistant.content == "DeepSeek-V4 is out."
|
||||
assert assistant.tool_calls is UNSET
|
||||
|
||||
|
||||
def test_provider_tools_to_anthropic_tools_maps_the_search_intent() -> None:
|
||||
"""The config default (``{"type": "web_search"}``) becomes the server tool."""
|
||||
assert provider_tools_to_anthropic_tools(None) == []
|
||||
assert provider_tools_to_anthropic_tools([]) == []
|
||||
assert provider_tools_to_anthropic_tools([{"type": "web_search"}]) == [
|
||||
{"type": "web_search_20250305", "name": "web_search"}
|
||||
]
|
||||
# A cap (or any other key) survives the mapping.
|
||||
assert provider_tools_to_anthropic_tools([{"type": "web_search", "max_uses": 3}]) == [
|
||||
{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}
|
||||
]
|
||||
# An entry that already names the surface's tool passes through.
|
||||
assert provider_tools_to_anthropic_tools([{"type": "web_search_20260209", "name": "web_search"}]) == [
|
||||
{"type": "web_search_20260209", "name": "web_search"}
|
||||
]
|
||||
# Entries without a type are dropped (nothing to declare on the wire).
|
||||
assert provider_tools_to_anthropic_tools([{"name": "web_search"}]) == []
|
||||
|
||||
|
||||
def test_chat_param_to_anthropic_param_merges_provider_tools_after_local_ones() -> None:
|
||||
param = ChatCompletionsParam(
|
||||
model="deepseek-flash",
|
||||
messages=[UserChatMessage(content="hi")],
|
||||
tools=[
|
||||
ToolFunctionItem(
|
||||
function=ToolFunction(
|
||||
name="read_file",
|
||||
description="Read a file.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
built = chat_param_to_anthropic_param(param, provider_tools=[{"type": "web_search"}])
|
||||
tools = built.tools
|
||||
assert tools is not UNSET
|
||||
assert isinstance(tools[0], AnthropicToolDefinition)
|
||||
assert tools[0].name == "read_file"
|
||||
assert tools[1] == {"type": "web_search_20250305", "name": "web_search"}
|
||||
|
||||
|
||||
def test_anthropic_response_to_chat_completion_usage() -> None:
|
||||
response = AnthropicMessageResponse(
|
||||
id="msg_1",
|
||||
@@ -198,3 +272,53 @@ def test_chat_param_to_anthropic_param() -> None:
|
||||
assert body.tools is not UNSET
|
||||
assert len(body.tools) == 1
|
||||
assert body.temperature == 0.2
|
||||
# No effort configured → no thinking block, provider default applies.
|
||||
assert body.thinking is UNSET
|
||||
|
||||
|
||||
def _effort_param(effort: ReasoningEffort | Unset = UNSET) -> ChatCompletionsParam:
|
||||
return ChatCompletionsParam(
|
||||
model="claude-test",
|
||||
messages=[UserChatMessage(content="hi")],
|
||||
reasoning_effort=effort,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_param_to_anthropic_param_thinking_from_effort() -> None:
|
||||
body = chat_param_to_anthropic_param(_effort_param("medium"))
|
||||
assert body.thinking is not UNSET
|
||||
assert body.thinking.type == "enabled"
|
||||
assert body.thinking.budget_tokens == 8192
|
||||
# A budget equal to max_tokens is invalid for Anthropic, so the default
|
||||
# output limit grows to leave room after it.
|
||||
assert body.max_tokens == 8192 + 1024
|
||||
|
||||
|
||||
def test_chat_param_to_anthropic_param_thinking_raises_max_tokens() -> None:
|
||||
# Anthropic requires budget_tokens < max_tokens; the answer keeps room after
|
||||
# the reasoning budget instead of the budget being shrunk to fit.
|
||||
body = chat_param_to_anthropic_param(_effort_param("high"))
|
||||
assert body.thinking is not UNSET
|
||||
assert body.thinking.budget_tokens == 16384
|
||||
assert body.max_tokens == 16384 + 1024
|
||||
|
||||
|
||||
def test_chat_param_to_anthropic_param_explicit_thinking_budget() -> None:
|
||||
body = chat_param_to_anthropic_param(_effort_param("minimal"), thinking_budget_tokens=20000)
|
||||
assert body.thinking is not UNSET
|
||||
assert body.thinking.budget_tokens == 20000
|
||||
assert body.max_tokens == 20000 + 1024
|
||||
|
||||
|
||||
def test_chat_param_to_anthropic_param_thinking_budget_floor() -> None:
|
||||
body = chat_param_to_anthropic_param(_effort_param("minimal"), thinking_budget_tokens=100)
|
||||
assert body.thinking is not UNSET
|
||||
assert body.thinking.budget_tokens == 1024
|
||||
assert body.max_tokens == 8192
|
||||
|
||||
|
||||
def test_chat_param_to_anthropic_param_thinking_disabled() -> None:
|
||||
body = chat_param_to_anthropic_param(_effort_param("none"))
|
||||
assert body.thinking is not UNSET
|
||||
assert body.thinking.type == "disabled"
|
||||
assert body.thinking.budget_tokens is UNSET
|
||||
|
||||
@@ -20,6 +20,7 @@ from plyngent.agent.messages_dispatch import dispatch_messages
|
||||
from plyngent.lmproto.anthropic.model import (
|
||||
AnthropicContentBlockDelta,
|
||||
AnthropicContentBlockStart,
|
||||
AnthropicContentBlockStop,
|
||||
AnthropicMessageDelta,
|
||||
AnthropicMessageResponse,
|
||||
AnthropicMessagesParam,
|
||||
@@ -71,6 +72,8 @@ class ScriptedMessagesClient:
|
||||
|
||||
kind: str = "messages"
|
||||
calls: list[AnthropicMessagesParam]
|
||||
# Mirrors AnthropicClient: explicit thinking budget (0 = derive from effort).
|
||||
thinking_budget_tokens: int
|
||||
_non_stream: list[AnthropicMessageResponse]
|
||||
_stream_events: list[list[AnthropicStreamEvent]]
|
||||
|
||||
@@ -81,6 +84,7 @@ class ScriptedMessagesClient:
|
||||
stream_events: Sequence[Sequence[AnthropicStreamEvent]] | None = None,
|
||||
) -> None:
|
||||
self.calls = []
|
||||
self.thinking_budget_tokens = 0
|
||||
self._non_stream = list(non_stream or ())
|
||||
self._stream_events = [list(events) for events in (stream_events or ())]
|
||||
|
||||
@@ -186,6 +190,46 @@ async def test_run_chat_loop_messages_kind_text() -> None:
|
||||
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"
|
||||
# No thinking configured → the request carries no thinking block.
|
||||
assert client.calls[0].thinking is UNSET
|
||||
|
||||
|
||||
async def test_run_chat_loop_passes_reasoning_effort_and_client_thinking_budget() -> None:
|
||||
client = ScriptedMessagesClient(non_stream=[_message_response(text="done")])
|
||||
client.thinking_budget_tokens = 12000
|
||||
events = [
|
||||
event
|
||||
async for event in run_chat_loop(
|
||||
cast("Any", client),
|
||||
[UserChatMessage(content="go")],
|
||||
model="claude-test",
|
||||
stream=False,
|
||||
reasoning_effort="high",
|
||||
)
|
||||
]
|
||||
assert any(isinstance(e, TextDeltaEvent) for e in events)
|
||||
thinking = client.calls[0].thinking
|
||||
assert thinking is not UNSET
|
||||
assert thinking.type == "enabled"
|
||||
# The client's explicit budget wins over the level's derived one.
|
||||
assert thinking.budget_tokens == 12000
|
||||
|
||||
|
||||
async def test_run_chat_loop_reasoning_effort_none_disables_thinking() -> None:
|
||||
client = ScriptedMessagesClient(non_stream=[_message_response(text="done")])
|
||||
_ = [
|
||||
event
|
||||
async for event in run_chat_loop(
|
||||
cast("Any", client),
|
||||
[UserChatMessage(content="go")],
|
||||
model="claude-test",
|
||||
stream=False,
|
||||
reasoning_effort="none",
|
||||
)
|
||||
]
|
||||
thinking = client.calls[0].thinking
|
||||
assert thinking is not UNSET
|
||||
assert thinking.type == "disabled"
|
||||
|
||||
|
||||
async def test_run_chat_loop_messages_kind_tool_round() -> None:
|
||||
@@ -350,3 +394,141 @@ async def test_dispatch_stream_tool_calls_without_stop_yields_no_finish() -> Non
|
||||
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)
|
||||
|
||||
|
||||
def _search_stream_events() -> list[AnthropicStreamEvent]:
|
||||
"""The stream a DeepSeek search turn sends: thinking, search, results, text."""
|
||||
return [
|
||||
AnthropicMessageStart(
|
||||
message=AnthropicMessageResponse(
|
||||
id="msg_s",
|
||||
model="deepseek-flash",
|
||||
content=[],
|
||||
usage=AnthropicUsage(input_tokens=164, output_tokens=0),
|
||||
)
|
||||
),
|
||||
AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="thinking", text="")),
|
||||
AnthropicContentBlockDelta(
|
||||
index=0,
|
||||
delta=AnthropicRawContentBlock(type="thinking_delta", text="Search."),
|
||||
),
|
||||
AnthropicContentBlockStop(index=0),
|
||||
AnthropicContentBlockStart(
|
||||
index=1,
|
||||
content_block=AnthropicRawContentBlock(type="server_tool_use", id="call_00_1", name="web_search", input={}),
|
||||
),
|
||||
AnthropicContentBlockDelta(
|
||||
index=1,
|
||||
delta=AnthropicRawContentBlock(type="input_json_delta", partial_json='{"query":'),
|
||||
),
|
||||
AnthropicContentBlockDelta(
|
||||
index=1,
|
||||
delta=AnthropicRawContentBlock(type="input_json_delta", partial_json='"DeepSeek-V4"}'),
|
||||
),
|
||||
AnthropicContentBlockStop(index=1),
|
||||
AnthropicContentBlockStart(
|
||||
index=2,
|
||||
content_block=AnthropicRawContentBlock(
|
||||
type="web_search_tool_result",
|
||||
id="call_00_1",
|
||||
input={"content": [{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}]},
|
||||
),
|
||||
),
|
||||
AnthropicContentBlockStop(index=2),
|
||||
AnthropicContentBlockStart(index=3, content_block=AnthropicRawContentBlock(type="text", text="")),
|
||||
AnthropicContentBlockDelta(
|
||||
index=3,
|
||||
delta=AnthropicRawContentBlock(type="text_delta", text="DeepSeek-V4 is out."),
|
||||
),
|
||||
AnthropicContentBlockStop(index=3),
|
||||
AnthropicMessageDelta(
|
||||
delta={"stop_reason": "end_turn"},
|
||||
usage=AnthropicUsage(input_tokens=0, output_tokens=12),
|
||||
),
|
||||
AnthropicMessageStop(),
|
||||
]
|
||||
|
||||
|
||||
async def test_search_turn_streams_its_text_without_a_local_tool_call() -> None:
|
||||
"""A server tool's arguments must not become a call for the registry.
|
||||
|
||||
DeepSeek streams a ``server_tool_use`` block whose ``input_json_delta``
|
||||
fragments carry the search query. Reading those as a local call's arguments
|
||||
asks the registry for a tool the *server* already ran — and would have the
|
||||
agent execute ``web_search`` (or a nameless tool) itself.
|
||||
"""
|
||||
client = ScriptedMessagesClient(stream_events=[_search_stream_events()])
|
||||
|
||||
@tool(register=False)
|
||||
def echo(text: str) -> str:
|
||||
return text
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in run_chat_loop(
|
||||
cast("Any", client),
|
||||
[UserChatMessage(content="what is DeepSeek-V4?")],
|
||||
model="deepseek-flash",
|
||||
tools=ToolRegistry([echo]),
|
||||
stream=True,
|
||||
)
|
||||
]
|
||||
assert not any(isinstance(event, ToolCallEvent) for event in events)
|
||||
assert not any(isinstance(event, ToolResultEvent) for event in events)
|
||||
assert any(isinstance(event, TextDeltaEvent) and event.content == "DeepSeek-V4 is out." for event in events)
|
||||
# One round: the search answered inside the response, so nothing was sent back.
|
||||
assert len(client.calls) == 1
|
||||
|
||||
|
||||
async def test_dispatch_messages_sends_provider_tools_as_server_tools() -> None:
|
||||
"""Hosted tools reach the Anthropic request in that surface's own shape."""
|
||||
client = ScriptedMessagesClient(non_stream=[_message_response(text="ok")])
|
||||
param = ChatCompletionsParam(model="deepseek-flash", messages=[UserChatMessage(content="hi")])
|
||||
_ = await dispatch_messages(
|
||||
cast("Any", client),
|
||||
param,
|
||||
provider_tools=[{"type": "web_search", "max_uses": 3}],
|
||||
)
|
||||
assert client.calls[0].tools == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}]
|
||||
|
||||
|
||||
async def test_dispatch_messages_without_provider_tools_sends_no_server_tool() -> None:
|
||||
client = ScriptedMessagesClient(non_stream=[_message_response(text="ok")])
|
||||
param = ChatCompletionsParam(model="deepseek-flash", messages=[UserChatMessage(content="hi")])
|
||||
_ = await dispatch_messages(cast("Any", client), param, provider_tools=[])
|
||||
assert client.calls[0].tools is UNSET
|
||||
|
||||
|
||||
async def test_search_turn_reports_the_prompt_the_delta_counts() -> None:
|
||||
"""A search turn's real prompt size only shows up on ``message_delta``.
|
||||
|
||||
DeepSeek's compat opens with the question alone (``message_start`` sees 165
|
||||
tokens) and reports what the search then pulled in on the delta (12468);
|
||||
keeping the smaller one leaves the CLI's context gauge — and the
|
||||
auto-compact threshold — that far short of what the model was sent.
|
||||
"""
|
||||
events: list[AnthropicStreamEvent] = [
|
||||
AnthropicMessageStart(
|
||||
message=AnthropicMessageResponse(
|
||||
id="msg_s",
|
||||
model="deepseek-flash",
|
||||
content=[],
|
||||
usage=AnthropicUsage(input_tokens=165, output_tokens=0),
|
||||
)
|
||||
),
|
||||
AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="text", text="")),
|
||||
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="text_delta", text="done")),
|
||||
AnthropicMessageDelta(
|
||||
delta={"stop_reason": "end_turn"},
|
||||
usage=AnthropicUsage(input_tokens=12468, output_tokens=198),
|
||||
),
|
||||
AnthropicMessageStop(),
|
||||
]
|
||||
client = ScriptedMessagesClient(stream_events=[events])
|
||||
param = ChatCompletionsParam(model="deepseek-flash", messages=[UserChatMessage(content="hi")])
|
||||
stream = await dispatch_messages(cast("Any", client), param, stream=True)
|
||||
chunks = [chunk async for chunk in cast("Any", stream)]
|
||||
usage = next(getattr(chunk, "usage", None) for chunk in chunks if getattr(chunk, "usage", None))
|
||||
assert isinstance(usage, dict)
|
||||
assert usage["prompt_tokens"] == 12468
|
||||
assert usage["completion_tokens"] == 198
|
||||
|
||||
@@ -13,12 +13,13 @@ from plyngent.agent.responses_bridge import (
|
||||
responses_status_to_finish_reason,
|
||||
tool_items_to_response_tools,
|
||||
)
|
||||
from plyngent.lmproto.openai.model import Response, ResponseFunctionToolCallOutput
|
||||
from plyngent.lmproto.openai.model import Response, ResponseEasyInputMessage, ResponseFunctionToolCallOutput
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
ChatCompletionsParam,
|
||||
DeveloperChatMessage,
|
||||
SystemChatMessage,
|
||||
ToolChatMessage,
|
||||
ToolFunction,
|
||||
@@ -27,6 +28,18 @@ from plyngent.lmproto.openai_compatible.model import (
|
||||
)
|
||||
|
||||
|
||||
def _input_item_types(
|
||||
items: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput],
|
||||
) -> list[str]:
|
||||
"""Item ``type`` tags, in stream order."""
|
||||
types: list[str] = []
|
||||
for item in items:
|
||||
tag = item.get("type") if isinstance(item, dict) else item.__struct_config__.tag
|
||||
assert isinstance(tag, str)
|
||||
types.append(tag)
|
||||
return types
|
||||
|
||||
|
||||
def test_tool_items_to_response_tools() -> None:
|
||||
items = [
|
||||
ToolFunctionItem(
|
||||
@@ -59,14 +72,18 @@ def test_chat_messages_to_input_and_instructions() -> None:
|
||||
]
|
||||
instructions, items = chat_messages_to_responses_input(messages)
|
||||
assert instructions == "You are helpful."
|
||||
assert len(items) == 3 # user, function_call, function_call_output
|
||||
# user message, then the call turn's reasoning + call, then its output.
|
||||
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
|
||||
|
||||
|
||||
def test_parallel_tool_outputs_interleaved_after_calls() -> None:
|
||||
"""Regression: DeepSeek Responses rejects call, call, output, output batches.
|
||||
def test_parallel_tool_outputs_group_after_the_rounds_calls() -> None:
|
||||
"""Regression: DeepSeek Responses rejected an interleaved batch.
|
||||
|
||||
Each ``function_call_output`` must directly follow its ``function_call`` so
|
||||
strict/adjacency-matching servers (DeepSeek) accept parallel tool rounds.
|
||||
A round is the assistant message its ``reasoning`` and ``function_call``
|
||||
items merge into, so its calls must stay contiguous: sent as
|
||||
``call, output, call, output`` the second call reads as a round of its own
|
||||
without reasoning, and the request comes back 400 ("The ``reasoning_text`` in
|
||||
the thinking mode must be passed back to the API").
|
||||
"""
|
||||
messages = [
|
||||
UserChatMessage(content="run both"),
|
||||
@@ -87,19 +104,20 @@ def test_parallel_tool_outputs_interleaved_after_calls() -> None:
|
||||
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]
|
||||
types = _input_item_types(items)
|
||||
assert types == [
|
||||
"message",
|
||||
"reasoning",
|
||||
"function_call",
|
||||
"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]
|
||||
# Each output follows the round's calls, paired with its own call.
|
||||
call_a = items[2]
|
||||
call_b = items[3]
|
||||
out_b = items[4]
|
||||
out_a = items[4]
|
||||
out_b = items[5]
|
||||
assert isinstance(call_a, dict) and isinstance(call_b, dict)
|
||||
assert isinstance(out_a, ResponseFunctionToolCallOutput)
|
||||
assert isinstance(out_b, ResponseFunctionToolCallOutput)
|
||||
@@ -107,6 +125,221 @@ def test_parallel_tool_outputs_interleaved_after_calls() -> None:
|
||||
assert out_b.call_id == call_b["call_id"]
|
||||
|
||||
|
||||
def test_tool_output_after_another_item_still_follows_its_call() -> None:
|
||||
"""A notice recorded between a call and its output does not split the pair.
|
||||
|
||||
The output is moved up beside the calls of its round; left where it was it
|
||||
would trail the notice's assistant message and be answered with "No tool
|
||||
output found for tool call …".
|
||||
"""
|
||||
messages = [
|
||||
AssistantChatMessage(
|
||||
content=None,
|
||||
reasoning_content="Read the file.",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_1",
|
||||
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
DeveloperChatMessage(content="reminder: keep going"),
|
||||
ToolChatMessage(content="file body", tool_call_id="call_1"),
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
assert _input_item_types(items) == [
|
||||
"reasoning",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
"message",
|
||||
]
|
||||
assert items[2] == ResponseFunctionToolCallOutput(call_id="call_1", output="file body")
|
||||
|
||||
|
||||
def test_tool_call_turn_carries_its_reasoning() -> None:
|
||||
"""Regression: DeepSeek rejected the tool-call round with a 422.
|
||||
|
||||
The reasoning item leads, directly ahead of the call it produced — DeepSeek
|
||||
merges it into that call's assistant message. Its ``content`` is a list of
|
||||
``reasoning_text`` parts, the only part the API reference documents for a
|
||||
``reasoning`` input item (a bare string is a deserialization error).
|
||||
"""
|
||||
messages = [
|
||||
UserChatMessage(content="read it"),
|
||||
AssistantChatMessage(
|
||||
content=None,
|
||||
reasoning_content="Open the file first.",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_1",
|
||||
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
ToolChatMessage(content="file body", tool_call_id="call_1"),
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
|
||||
assert items[1] == {
|
||||
"type": "reasoning",
|
||||
"content": [{"type": "reasoning_text", "text": "Open the file first."}],
|
||||
}
|
||||
|
||||
|
||||
def test_parallel_call_turn_keeps_one_reasoning_item() -> None:
|
||||
"""A batch keeps the round's single reasoning item, ahead of its calls."""
|
||||
messages = [
|
||||
AssistantChatMessage(
|
||||
content=None,
|
||||
reasoning_content="Both are independent.",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_a",
|
||||
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
||||
),
|
||||
AssistantFunctionToolCall(
|
||||
id="call_b",
|
||||
function=AssistantFunctionTool(name="regex_files", arguments='{"pattern":"todo"}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
ToolChatMessage(content="file body", tool_call_id="call_a"),
|
||||
ToolChatMessage(content="grep hit", tool_call_id="call_b"),
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
assert _input_item_types(items) == [
|
||||
"reasoning",
|
||||
"function_call",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
"function_call_output",
|
||||
]
|
||||
assert items[0] == {
|
||||
"type": "reasoning",
|
||||
"content": [{"type": "reasoning_text", "text": "Both are independent."}],
|
||||
}
|
||||
|
||||
|
||||
def test_round_with_text_and_a_call_puts_the_text_before_the_call() -> None:
|
||||
"""A call round ends on its tool output, never on a trailing text message.
|
||||
|
||||
A request that ended with the round's text *after* its own output was
|
||||
answered with a 400 ("the `reasoning_text` … must be passed back"): the
|
||||
text has to stay with its reasoning, ahead of the calls.
|
||||
"""
|
||||
messages = [
|
||||
UserChatMessage(content="read it"),
|
||||
AssistantChatMessage(
|
||||
content="Reading the file now.",
|
||||
reasoning_content="Open the file first.",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_1",
|
||||
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
ToolChatMessage(content="file body", tool_call_id="call_1"),
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
assert _input_item_types(items) == ["message", "reasoning", "message", "function_call", "function_call_output"]
|
||||
|
||||
|
||||
def test_answer_round_without_reasoning_sends_a_blank_part() -> None:
|
||||
"""A full part is required, not just the item.
|
||||
|
||||
An empty ``reasoning_text`` is answered with the same 400 as a missing item,
|
||||
so a round that kept no chain-of-thought — the seed a compacted session
|
||||
starts with, a round recorded before the reasoning was read back — sends a
|
||||
blank line rather than words of the bridge's own. The rule is per round: an
|
||||
answer that called nothing is checked too.
|
||||
"""
|
||||
messages = [
|
||||
UserChatMessage(content="hi"),
|
||||
AssistantChatMessage(content="hello"),
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
assert _input_item_types(items) == ["message", "reasoning", "message"]
|
||||
assert items[1] == {
|
||||
"type": "reasoning",
|
||||
"content": [{"type": "reasoning_text", "text": " "}],
|
||||
}
|
||||
|
||||
|
||||
def test_answer_turn_with_reasoning_carries_it() -> None:
|
||||
"""A round that called nothing still hands its reasoning back.
|
||||
|
||||
Thinking Mode → Tool Calls: a request carrying ``tools`` must return the
|
||||
reasoning of every round, "even if the round did not actually call a tool";
|
||||
a missing one is a 400.
|
||||
"""
|
||||
messages = [
|
||||
UserChatMessage(content="hi"),
|
||||
AssistantChatMessage(content="hello", reasoning_content="They greet me, so I greet back."),
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
assert _input_item_types(items) == ["message", "reasoning", "message"]
|
||||
assert items[1] == {
|
||||
"type": "reasoning",
|
||||
"content": [{"type": "reasoning_text", "text": "They greet me, so I greet back."}],
|
||||
}
|
||||
|
||||
|
||||
def test_forged_call_round_sends_a_blank_part() -> None:
|
||||
"""A synthetic nag assistant carries ``reasoning_content=""``.
|
||||
|
||||
The round still needs reasoning text, so a blank line goes back with it
|
||||
rather than an empty part (which the API rejects just like a missing item).
|
||||
"""
|
||||
messages = [
|
||||
AssistantChatMessage(
|
||||
content=None,
|
||||
reasoning_content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_nag_1",
|
||||
function=AssistantFunctionTool(name="todo_list", arguments="{}"),
|
||||
)
|
||||
],
|
||||
),
|
||||
ToolChatMessage(content="stack", tool_call_id="call_nag_1"),
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
assert _input_item_types(items) == ["reasoning", "function_call", "function_call_output"]
|
||||
assert items[0] == {
|
||||
"type": "reasoning",
|
||||
"content": [{"type": "reasoning_text", "text": " "}],
|
||||
}
|
||||
|
||||
|
||||
def test_rolled_back_call_round_sends_a_blank_part() -> None:
|
||||
"""A kept call that recorded no reasoning still carries reasoning text.
|
||||
|
||||
History from before the chain-of-thought was read back, and a carrier
|
||||
``rollback_tail`` wrote without one, hold a call with no reasoning at all;
|
||||
the round must return text, so a blank line goes back with it (both a
|
||||
missing item and an empty part are a 400).
|
||||
"""
|
||||
messages = [
|
||||
AssistantChatMessage(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_kept",
|
||||
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
ToolChatMessage(content="file body", tool_call_id="call_kept"),
|
||||
]
|
||||
_, items = chat_messages_to_responses_input(messages)
|
||||
assert _input_item_types(items) == ["reasoning", "function_call", "function_call_output"]
|
||||
assert items[0] == {
|
||||
"type": "reasoning",
|
||||
"content": [{"type": "reasoning_text", "text": " "}],
|
||||
}
|
||||
|
||||
|
||||
def test_response_to_assistant_with_tools() -> None:
|
||||
raw = {
|
||||
"id": "resp_1",
|
||||
@@ -167,7 +400,7 @@ def _reasoning_response(
|
||||
return Response(
|
||||
id="resp_r",
|
||||
created_at=1,
|
||||
model="deepseek-v4-flash",
|
||||
model="deepseek-flash",
|
||||
output=output,
|
||||
reasoning=top_level,
|
||||
)
|
||||
@@ -194,6 +427,41 @@ def test_reasoning_summary_text_falls_back_to_output_items() -> None:
|
||||
assert reasoning_summary_text(response) == "openai-style reasoning"
|
||||
|
||||
|
||||
def test_reasoning_summary_text_reads_output_item_content() -> None:
|
||||
"""DeepSeek's reasoning item carries the chain-of-thought in ``content``.
|
||||
|
||||
The parts are ``reasoning_text`` and ``summary`` comes back empty (see the
|
||||
API reference sample), so reading ``summary`` alone finds nothing — and a
|
||||
tool-call turn then goes back without the reasoning thinking mode wants.
|
||||
"""
|
||||
body = {
|
||||
"id": "resp_c",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"model": "deepseek-flash",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"status": "completed",
|
||||
"content": [{"type": "reasoning_text", "text": "The user greets me."}],
|
||||
"summary": [],
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "read_file",
|
||||
"arguments": '{"path":"a"}',
|
||||
"status": "completed",
|
||||
},
|
||||
],
|
||||
}
|
||||
response = msgspec.convert(body, Response)
|
||||
assert reasoning_summary_text(response) == "The user greets me."
|
||||
assert response_to_assistant_message(response).reasoning_content == "The user greets me."
|
||||
|
||||
|
||||
def test_reasoning_summary_text_empty_when_absent() -> None:
|
||||
assert reasoning_summary_text(_reasoning_response()) == ""
|
||||
|
||||
@@ -235,6 +503,18 @@ def test_chat_param_to_responses_kwargs() -> None:
|
||||
assert kwargs["store"] is False
|
||||
assert kwargs["temperature"] == 0.2
|
||||
assert len(kwargs["tools"]) == 1
|
||||
# No effort configured → no reasoning block.
|
||||
assert "reasoning" not in kwargs
|
||||
|
||||
|
||||
def test_chat_param_to_responses_kwargs_reasoning_effort() -> None:
|
||||
param = ChatCompletionsParam(
|
||||
model="gpt-test",
|
||||
messages=[UserChatMessage(content="hi")],
|
||||
reasoning_effort="xhigh",
|
||||
)
|
||||
kwargs = chat_param_to_responses_kwargs(param)
|
||||
assert kwargs["reasoning"].effort == "xhigh"
|
||||
|
||||
|
||||
def test_provider_tools_merged_after_local_functions() -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ from msgspec import UNSET
|
||||
|
||||
from plyngent.agent import (
|
||||
AssistantMessageEvent,
|
||||
ReasoningDeltaEvent,
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolRegistry,
|
||||
@@ -258,6 +259,65 @@ async def test_dispatch_responses_stream_reasoning_no_duplicate() -> None:
|
||||
assert reasoning == "streamed only"
|
||||
|
||||
|
||||
async def _loop_reasoning_deltas(
|
||||
events: list[ResponseStreamEvent],
|
||||
messages: list[Any],
|
||||
) -> list[str]:
|
||||
"""Run one streamed round and collect the reasoning deltas the loop emitted."""
|
||||
import msgspec
|
||||
|
||||
final = _completed_response(text="ok")
|
||||
scripted = [*events, ResponseStreamEvent(type="response.completed", response=msgspec.to_builtins(final))]
|
||||
client = ScriptedResponsesClient(stream_events=[scripted])
|
||||
return [
|
||||
event.content
|
||||
async for event in run_chat_loop(cast("Any", client), messages, model="gpt-test", stream=True)
|
||||
if isinstance(event, ReasoningDeltaEvent)
|
||||
]
|
||||
|
||||
|
||||
async def test_loop_responses_reasoning_done_is_not_printed_twice() -> None:
|
||||
"""``response.reasoning_text.done`` repeats what its fragments spelled out.
|
||||
|
||||
Streaming it verbatim printed the chain of thought twice (the fragments, then
|
||||
the whole text); the round still hands the full text back to the API.
|
||||
"""
|
||||
fragments = ["We", " need", " answer."]
|
||||
events = [ResponseStreamEvent(type="response.reasoning_text.delta", delta=part) for part in fragments]
|
||||
events.append(ResponseStreamEvent(type="response.reasoning_text.done", text="We need answer."))
|
||||
messages: list[Any] = [UserChatMessage(content="hi")]
|
||||
|
||||
deltas = await _loop_reasoning_deltas(events, messages)
|
||||
|
||||
assert deltas == fragments
|
||||
assert messages[-1].reasoning_content == "We need answer."
|
||||
|
||||
|
||||
async def test_loop_responses_reasoning_done_alone_is_shown() -> None:
|
||||
"""A round whose fragments never arrived still shows the ``.done`` text once."""
|
||||
events = [ResponseStreamEvent(type="response.reasoning_text.done", text="chain of thought")]
|
||||
messages: list[Any] = [UserChatMessage(content="hi")]
|
||||
|
||||
deltas = await _loop_reasoning_deltas(events, messages)
|
||||
|
||||
assert deltas == ["chain of thought"]
|
||||
assert messages[-1].reasoning_content == "chain of thought"
|
||||
|
||||
|
||||
async def test_loop_responses_reasoning_done_extends_fragments() -> None:
|
||||
"""A ``.done`` text longer than what streamed shows only its unseen tail."""
|
||||
events = [
|
||||
ResponseStreamEvent(type="response.reasoning_text.delta", delta="We need"),
|
||||
ResponseStreamEvent(type="response.reasoning_text.done", text="We need answer."),
|
||||
]
|
||||
messages: list[Any] = [UserChatMessage(content="hi")]
|
||||
|
||||
deltas = await _loop_reasoning_deltas(events, messages)
|
||||
|
||||
assert deltas == ["We need", " answer."]
|
||||
assert messages[-1].reasoning_content == "We need answer."
|
||||
|
||||
|
||||
def token_usage_present(chunk: object) -> bool:
|
||||
usage = getattr(chunk, "usage", UNSET)
|
||||
return usage is not UNSET and usage is not None
|
||||
@@ -430,7 +490,7 @@ async def test_dispatch_stream_failed_raises_with_error_detail() -> None:
|
||||
"id": "resp_1",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"model": "deepseek-v4-flash",
|
||||
"model": "deepseek-flash",
|
||||
"status": "failed",
|
||||
"output": [],
|
||||
"error": {"code": "rate_limit_exceeded", "message": "slow down"},
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Rollback of a failed turn keeps the tool work that already ran.
|
||||
|
||||
``committed_prefix_end`` commits a batch only once every call has its result, so
|
||||
``rollback_tail`` keeps the executed part of a batch that was cut short: the
|
||||
retry then continues from real results instead of running those tools again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.agent.chat import rollback_tail
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AnyChatMessage,
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
DeveloperChatMessage,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
|
||||
def _call(call_id: str, name: str = "some_tool") -> AssistantFunctionToolCall:
|
||||
return AssistantFunctionToolCall(id=call_id, function=AssistantFunctionTool(name=name, arguments="{}"))
|
||||
|
||||
|
||||
def test_rollback_tail_keeps_the_executed_call_of_a_cut_short_batch() -> None:
|
||||
messages: list[AnyChatMessage] = [
|
||||
UserChatMessage(content="do both"),
|
||||
AssistantChatMessage(
|
||||
content="first, then the other",
|
||||
reasoning_content="step by step",
|
||||
tool_calls=[_call("call_a"), _call("call_b")],
|
||||
),
|
||||
ToolChatMessage(tool_call_id="call_a", content="a ran"),
|
||||
]
|
||||
|
||||
tail = rollback_tail(messages, 1)
|
||||
|
||||
assert len(tail) == 2
|
||||
record, result = tail
|
||||
assert isinstance(record, AssistantChatMessage)
|
||||
# The assistant row survives as the carrier of the pairing, with the
|
||||
# reasoning thinking mode demands on a call.
|
||||
assert record.content is UNSET
|
||||
assert record.reasoning_content == "step by step"
|
||||
calls = record.tool_calls
|
||||
assert calls is not UNSET
|
||||
assert [call.id for call in calls] == ["call_a"]
|
||||
assert isinstance(result, ToolChatMessage)
|
||||
assert result.content == "a ran"
|
||||
|
||||
|
||||
def test_rollback_tail_drops_a_batch_that_reported_nothing() -> None:
|
||||
messages: list[AnyChatMessage] = [
|
||||
UserChatMessage(content="go"),
|
||||
AssistantChatMessage(content="about to call", tool_calls=[_call("call_a")]),
|
||||
]
|
||||
|
||||
assert rollback_tail(messages, 1) == []
|
||||
|
||||
|
||||
def test_rollback_tail_drops_unfinished_text_and_keeps_developer_rows() -> None:
|
||||
messages: list[AnyChatMessage] = [
|
||||
UserChatMessage(content="go"),
|
||||
AssistantChatMessage(content=UNSET, tool_calls=[_call("call_a")]),
|
||||
ToolChatMessage(tool_call_id="call_a", content="a ran"),
|
||||
DeveloperChatMessage(content="checkpoint"),
|
||||
AssistantChatMessage(content="half-written answer"),
|
||||
]
|
||||
|
||||
tail = rollback_tail(messages, 1)
|
||||
|
||||
assert [type(message) for message in tail] == [AssistantChatMessage, ToolChatMessage, DeveloperChatMessage]
|
||||
|
||||
|
||||
def test_rollback_tail_pairs_a_repeated_call_id_once() -> None:
|
||||
messages: list[AnyChatMessage] = [
|
||||
UserChatMessage(content="go"),
|
||||
AssistantChatMessage(content=UNSET, tool_calls=[_call("call_a"), _call("call_a")]),
|
||||
ToolChatMessage(tool_call_id="call_a", content="only once"),
|
||||
]
|
||||
|
||||
tail = rollback_tail(messages, 1)
|
||||
|
||||
assert len(tail) == 2
|
||||
record, result = tail
|
||||
assert isinstance(record, AssistantChatMessage)
|
||||
calls = record.tool_calls
|
||||
assert calls is not UNSET
|
||||
assert [call.id for call in calls] == ["call_a"]
|
||||
assert isinstance(result, ToolChatMessage)
|
||||
assert result.content == "only once"
|
||||
|
||||
|
||||
def test_rollback_tail_ignores_rows_before_start() -> None:
|
||||
messages: list[AnyChatMessage] = [
|
||||
UserChatMessage(content="go"),
|
||||
AssistantChatMessage(content=UNSET, tool_calls=[_call("call_a")]),
|
||||
ToolChatMessage(tool_call_id="call_a", content="a ran"),
|
||||
]
|
||||
|
||||
assert rollback_tail(messages, len(messages)) == []
|
||||
@@ -186,6 +186,51 @@ async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert "[tool]" not in out
|
||||
|
||||
|
||||
async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A batch of todo calls: one summary line, then the last result's stack."""
|
||||
stack = "(LIFO of groups: depth=1; TOP group = current breakdown level)\ngroup d=0 TOP:\n [ ] a1: T1"
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("todo_push", '{"titles": ["T1"]}'),
|
||||
_pretty_call("todo_update", '{"item_id": "a1", "status": "done"}'),
|
||||
_result(f"pushed group (depth=1) items=[a1]\n{stack}"),
|
||||
_result(f"updated a1 → done: T1\n{stack}"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Todo x2: Push, Update" in out
|
||||
assert "* Todo Push:" not in out
|
||||
assert "* Todo Update:" not in out
|
||||
# Only the last result is printed, so the stack renders once.
|
||||
assert out.count("group d=0 TOP:") == 1
|
||||
assert "pushed group" not in out
|
||||
assert "updated a1 → done: T1" in out
|
||||
|
||||
|
||||
async def test_pretty_todo_batch_mixed_with_other_tool_stays_per_call(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A batch holding a non-todo call keeps one line (and stack) per todo call."""
|
||||
stack = "group d=0 TOP:\n [ ] a1: T1"
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("todo_push", '{"titles": ["T1"]}'),
|
||||
_pretty_call("listdir", '{"path": "src"}'),
|
||||
_result(f"pushed group (depth=1) items=[a1]\n{stack}"),
|
||||
_result("dir\tsrc\nfile\tREADME.md\n"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "* Todo Push:" in out
|
||||
assert "group d=0 TOP:" in out
|
||||
assert "src" in out
|
||||
assert "* Todo x" not in out
|
||||
|
||||
|
||||
async def test_non_pretty_tool_keeps_old_style(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Tools with no pretty line (plugins, unknown names) keep the ``[tool]`` style."""
|
||||
call = ToolCallEvent(
|
||||
@@ -464,6 +509,42 @@ async def test_pretty_fetch_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert "* Fetch GET https://example.com (error: fetch failed: boom)" in out
|
||||
|
||||
|
||||
async def test_pretty_web_search(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
result = (
|
||||
"- CPython free-threading — https://example.com/ft\n"
|
||||
" PEP 703 landed in 3.13\n"
|
||||
"- Another page — https://example.com/other\n"
|
||||
)
|
||||
call = _pretty_call("web_search", '{"query": "python 3.14 free-threading"}')
|
||||
await render_events(_aiter([call, _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Web Search 'python 3.14 free-threading' (2 hits)" in out
|
||||
assert "[tool]" not in out
|
||||
assert "[tool ok]" not in out
|
||||
|
||||
|
||||
async def test_pretty_web_search_truncates_the_query(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
query = "a" * 80
|
||||
await render_events(
|
||||
_aiter([_pretty_call("web_search", f'{{"query": "{query}"}}'), _result("- One — https://example.com\n")])
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert f"* Web Search '{'a' * 60}…' (1 hit)" in out
|
||||
|
||||
|
||||
async def test_pretty_web_search_no_results(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
result = "error: the search found nothing (DeepseekSearchBackend: no results; BingSearchBackend: no results)"
|
||||
await render_events(_aiter([_pretty_call("web_search", '{"query": "zzz"}'), _result(result)]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Web Search 'zzz' (no results)" in out
|
||||
|
||||
|
||||
async def test_pretty_web_search_failing_source(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
await render_events(_aiter([_pretty_call("web_search", '{"query": "x"}'), _result("error: search HTTP 401")]))
|
||||
out = capsys.readouterr().out
|
||||
assert "* Web Search 'x' (error: search HTTP 401)" in out
|
||||
|
||||
|
||||
async def test_pretty_mutators(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
cases = [
|
||||
("write_file", '{"path": "src/x.py"}', "wrote 120 characters to src/x.py", "* Write 'src/x.py' (120 chars)"),
|
||||
@@ -817,8 +898,13 @@ async def test_pretty_mcp_tools_use_one_generic_line(capsys: pytest.CaptureFixtu
|
||||
def test_every_builtin_tool_has_a_pretty_line() -> None:
|
||||
"""A new builtin tool must ship a renderer, or it silently loses the syntax."""
|
||||
from plyngent.tools.catalog import default_tool_definitions
|
||||
from plyngent.tools.net.search import WEB_SEARCH_TOOL_NAME
|
||||
|
||||
missing = [d.name for d in default_tool_definitions() if _pretty_line_for(d.name) is None]
|
||||
names = [d.name for d in default_tool_definitions()]
|
||||
# The CLI host composes ``web_search`` over the builtins, so a catalog walk
|
||||
# on its own would let a renderer-less tool ship.
|
||||
names.append(WEB_SEARCH_TOOL_NAME)
|
||||
missing = [name for name in names if _pretty_line_for(name) is None]
|
||||
assert missing == []
|
||||
|
||||
|
||||
@@ -952,11 +1038,11 @@ async def test_pretty_verbose_keeps_full_result_output(
|
||||
assert "* Read 'a.txt' " not in out
|
||||
|
||||
|
||||
async def test_pretty_parallel_batch_closes_the_open_prefix(
|
||||
async def test_pretty_parallel_batch_gives_every_call_its_own_row(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A second call in the batch ends the open prefix; results print whole lines."""
|
||||
"""A second call in the batch holds its prefix until the first row is done."""
|
||||
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
|
||||
await render_events(
|
||||
_aiter(
|
||||
@@ -969,16 +1055,35 @@ async def test_pretty_parallel_batch_closes_the_open_prefix(
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
# The prefix survives as its own line; whole lines take over from there.
|
||||
assert out == ("\n* Read 'a.txt' \n\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n")
|
||||
assert out == ("\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n")
|
||||
assert "\x1b[" not in out # the cursor is never moved
|
||||
|
||||
|
||||
async def test_pretty_edit_batch_prints_one_row_per_edit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Two edits in one round: one finished row each, no abandoned prefix row."""
|
||||
monkeypatch.setattr("plyngent.cli.display.interactive_terminal", lambda: True)
|
||||
await render_events(
|
||||
_aiter(
|
||||
[
|
||||
_pretty_call("edit_replace", '{"path": "a.py"}'),
|
||||
_pretty_call("edit_replace", '{"path": "b.py"}'),
|
||||
_result("replaced 1 occurrence in a.py ('x' → 'y')"),
|
||||
_result("replaced 1 occurrence in b.py ('x' → 'y')"),
|
||||
]
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert out == "\n* Edit 'a.py' (done)\n\n* Edit 'b.py' (done)\n\n"
|
||||
|
||||
|
||||
async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Ending the open prefix must not touch the line above it.
|
||||
"""Rendering a batch must not touch the line above the prefix.
|
||||
|
||||
Reasoning streams without a trailing newline, so the prefix line sits right
|
||||
below the reasoning text; moving the cursor up used to wipe that reasoning
|
||||
@@ -997,9 +1102,7 @@ async def test_pretty_parallel_batch_keeps_the_line_above_the_prefix(
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert out == (
|
||||
"\nreasoning:\nthink\n* Read 'a.txt' \n\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n\n"
|
||||
)
|
||||
assert out == ("\nreasoning:\nthink\n* Read 'a.txt' L1-4 (done)\n\n* Read 'b.txt' L1-9 (done)\n\n\n")
|
||||
assert "\x1b[" not in out
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Wiring of resolved thinking settings into the CLI session state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import tomlkit
|
||||
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.config.models import AnthropicProvider, DatabaseConfig, ModelConfig
|
||||
from plyngent.config.store import ConfigStore
|
||||
from plyngent.lmproto.anthropic import AnthropicClient
|
||||
from plyngent.memory import MemoryStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_DOCUMENT = """
|
||||
[agent]
|
||||
reasoning_effort = "low"
|
||||
thinking_budget_tokens = 2000
|
||||
"""
|
||||
|
||||
|
||||
async def _state(tmp_path: Path, provider: AnthropicProvider, model: str) -> tuple[ReplState, MemoryStore]:
|
||||
memory = await MemoryStore.open(DatabaseConfig())
|
||||
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.parse(_DOCUMENT))
|
||||
config.providers = {"local": provider}
|
||||
state = ReplState(
|
||||
config=config,
|
||||
memory=memory,
|
||||
workspace=tmp_path,
|
||||
provider_name="local",
|
||||
provider=provider,
|
||||
model=model,
|
||||
tools_enabled=False,
|
||||
)
|
||||
await state.new_session("t")
|
||||
return state, memory
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_and_client_get_resolved_thinking_settings(tmp_path: Path) -> None:
|
||||
provider = AnthropicProvider(
|
||||
access_key_or_token="sk-test",
|
||||
reasoning_effort="high",
|
||||
models={"claude-test": ModelConfig(reasoning_effort="max")},
|
||||
)
|
||||
state, memory = await _state(tmp_path, provider, "claude-test")
|
||||
try:
|
||||
# Model layer wins over the provider layer, and both over ``[agent]``.
|
||||
assert state.agent.reasoning_effort == "xhigh"
|
||||
assert isinstance(state.client, AnthropicClient)
|
||||
assert state.client.thinking_budget_tokens == 2000
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_layer_wins_when_model_has_no_override(tmp_path: Path) -> None:
|
||||
provider = AnthropicProvider(access_key_or_token="sk-test", thinking_budget_tokens=9000)
|
||||
state, memory = await _state(tmp_path, provider, "claude-test")
|
||||
try:
|
||||
assert state.agent.reasoning_effort == "low"
|
||||
assert isinstance(state.client, AnthropicClient)
|
||||
assert state.client.thinking_budget_tokens == 9000
|
||||
finally:
|
||||
await memory.close()
|
||||
@@ -29,6 +29,8 @@ def test_agent_section_defaults(tmp_path: Path) -> None:
|
||||
assert store.agent_config.allow_paths == {}
|
||||
assert store.agent_config.auto_continue_limits is False
|
||||
assert store.agent_config.max_context_tokens == 200_000
|
||||
assert store.agent_config.reasoning_effort == ""
|
||||
assert store.agent_config.thinking_budget_tokens == 0
|
||||
assert store.plugins_config.enable == []
|
||||
assert store.plugins_config.disable == []
|
||||
assert store.networking_config.ssrf_assume_public_cidrs == []
|
||||
|
||||
@@ -46,8 +46,8 @@ def test_read_valid_config() -> None:
|
||||
assert isinstance(providers["test3"], AnthropicProvider)
|
||||
assert isinstance(providers["foo1"], DeepseekProvider)
|
||||
# TOML omitted models → DeepSeek defaults.
|
||||
assert set(providers["foo1"].models) == {"deepseek-v4-flash", "deepseek-v4-pro"}
|
||||
assert providers["foo1"].models["deepseek-v4-flash"].text is True
|
||||
assert set(providers["foo1"].models) == {"deepseek-flash", "deepseek-v4-pro"}
|
||||
assert providers["foo1"].models["deepseek-flash"].text is True
|
||||
db = config.database
|
||||
assert db["implementation"] == "sqlite"
|
||||
assert db["url"] == ":memory:"
|
||||
@@ -57,7 +57,7 @@ def test_read_valid_config() -> None:
|
||||
|
||||
def test_deepseek_default_models_on_construct() -> None:
|
||||
provider = DeepseekProvider(access_key_or_token="sk-test")
|
||||
assert set(provider.models) == {"deepseek-v4-flash", "deepseek-v4-pro"}
|
||||
assert set(provider.models) == {"deepseek-flash", "deepseek-v4-pro"}
|
||||
|
||||
|
||||
def test_openai_default_models_on_construct() -> None:
|
||||
@@ -179,7 +179,7 @@ preset = "deepseek"
|
||||
access_key_or_token = "sk-test"
|
||||
|
||||
[providers.ds.models]
|
||||
"deepseek-v4-flash" = { text = true, convention = "responses" }
|
||||
"deepseek-flash" = { text = true, convention = "responses" }
|
||||
"deepseek-v4-pro" = { text = true }
|
||||
""",
|
||||
encoding="utf-8",
|
||||
@@ -187,7 +187,7 @@ access_key_or_token = "sk-test"
|
||||
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-flash"].convention == "responses"
|
||||
assert provider.models["deepseek-v4-pro"].convention == ""
|
||||
|
||||
|
||||
@@ -299,6 +299,70 @@ def test_read_bad_config() -> None:
|
||||
assert isinstance(config.bad_providers, Mapping)
|
||||
|
||||
|
||||
def test_reasoning_effort_from_toml(tmp_path: Path) -> None:
|
||||
path = tmp_path / "reasoning.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[agent]
|
||||
reasoning_effort = "low"
|
||||
thinking_budget_tokens = 4000
|
||||
|
||||
[providers.ds]
|
||||
preset = "deepseek"
|
||||
access_key_or_token = "sk-test"
|
||||
reasoning_effort = "high"
|
||||
|
||||
[providers.ds.models]
|
||||
"deepseek-v4-pro" = { text = true, reasoning_effort = "max", thinking_budget_tokens = 32000 }
|
||||
"deepseek-flash" = { text = true }
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = plyngent.config.load(path)
|
||||
agent_cfg = config.agent_config
|
||||
assert agent_cfg.reasoning_effort == "low"
|
||||
assert agent_cfg.thinking_budget_tokens == 4000
|
||||
provider = config.providers["ds"]
|
||||
assert provider.reasoning_effort == "high"
|
||||
assert provider.thinking_budget_tokens == 0
|
||||
assert provider.models["deepseek-v4-pro"].reasoning_effort == "max"
|
||||
assert provider.models["deepseek-v4-pro"].thinking_budget_tokens == 32000
|
||||
assert provider.models["deepseek-flash"].reasoning_effort == ""
|
||||
assert provider.models["deepseek-flash"].thinking_budget_tokens == 0
|
||||
|
||||
|
||||
def test_reasoning_effort_defaults_are_unset(tmp_path: Path) -> None:
|
||||
path = tmp_path / "reasoning-default.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[providers.ds]
|
||||
preset = "deepseek"
|
||||
access_key_or_token = "sk-test"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = plyngent.config.load(path)
|
||||
assert config.agent_config.reasoning_effort == ""
|
||||
assert config.agent_config.thinking_budget_tokens == 0
|
||||
assert config.providers["ds"].reasoning_effort == ""
|
||||
|
||||
|
||||
def test_reasoning_effort_invalid_is_bad_provider(tmp_path: Path) -> None:
|
||||
path = tmp_path / "reasoning-bad.toml"
|
||||
_ = path.write_text(
|
||||
"""
|
||||
[providers.ds]
|
||||
preset = "deepseek"
|
||||
access_key_or_token = "sk-test"
|
||||
reasoning_effort = "highest"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = plyngent.config.load(path)
|
||||
assert "ds" not in config.providers
|
||||
assert "ds" in config.bad_providers
|
||||
|
||||
|
||||
def test_provider_with_empty_models_is_recoverable(tmp_path: Path) -> None:
|
||||
path = tmp_path / "empty-models.toml"
|
||||
_ = path.write_text(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Tests for thinking-strength resolution across config layers and surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.config.models import (
|
||||
AgentConfig,
|
||||
AnthropicProvider,
|
||||
DeepseekProvider,
|
||||
ModelConfig,
|
||||
OpenAICompatibleProvider,
|
||||
OpenAIProvider,
|
||||
)
|
||||
from plyngent.config.reasoning import resolve_reasoning
|
||||
|
||||
|
||||
def _compat() -> OpenAICompatibleProvider:
|
||||
return OpenAICompatibleProvider(access_key_or_token="sk-test", url="https://example.com/v1")
|
||||
|
||||
|
||||
def test_agent_layer_only() -> None:
|
||||
reasoning = resolve_reasoning(
|
||||
AgentConfig(reasoning_effort="high", thinking_budget_tokens=9000),
|
||||
_compat(),
|
||||
"gpt-4o-mini",
|
||||
)
|
||||
assert reasoning.effort == "high"
|
||||
assert reasoning.thinking_budget_tokens == 9000
|
||||
|
||||
|
||||
def test_provider_overrides_agent_and_model_overrides_provider() -> None:
|
||||
provider = OpenAICompatibleProvider(
|
||||
access_key_or_token="sk-test",
|
||||
url="https://example.com/v1",
|
||||
reasoning_effort="low",
|
||||
models={"gpt-4o-mini": ModelConfig(reasoning_effort="xhigh")},
|
||||
)
|
||||
agent = AgentConfig(reasoning_effort="minimal")
|
||||
assert resolve_reasoning(agent, provider, "gpt-4o-mini").effort == "xhigh"
|
||||
assert resolve_reasoning(agent, provider, "other-model").effort == "low"
|
||||
assert resolve_reasoning(agent, provider, None).effort == "low"
|
||||
|
||||
|
||||
def test_fields_fall_back_independently() -> None:
|
||||
provider = OpenAICompatibleProvider(
|
||||
access_key_or_token="sk-test",
|
||||
url="https://example.com/v1",
|
||||
reasoning_effort="high",
|
||||
thinking_budget_tokens=20000,
|
||||
models={"gpt-4o-mini": ModelConfig(thinking_budget_tokens=4000)},
|
||||
)
|
||||
reasoning = resolve_reasoning(AgentConfig(reasoning_effort="low"), provider, "gpt-4o-mini")
|
||||
# Model sets the budget only → the effort still comes from the provider.
|
||||
assert reasoning.effort == "high"
|
||||
assert reasoning.thinking_budget_tokens == 4000
|
||||
|
||||
|
||||
def test_agent_layer_optional() -> None:
|
||||
provider = OpenAICompatibleProvider(
|
||||
access_key_or_token="sk-test",
|
||||
url="https://example.com/v1",
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
assert resolve_reasoning(None, provider, "gpt-4o-mini").effort == "medium"
|
||||
|
||||
|
||||
def test_unset_layers_stay_unspecified() -> None:
|
||||
reasoning = resolve_reasoning(None, _compat(), "gpt-4o-mini")
|
||||
assert reasoning.effort == ""
|
||||
assert reasoning.thinking_budget_tokens == 0
|
||||
|
||||
|
||||
def test_non_positive_budget_is_ignored() -> None:
|
||||
provider = OpenAICompatibleProvider(
|
||||
access_key_or_token="sk-test",
|
||||
url="https://example.com/v1",
|
||||
thinking_budget_tokens=0,
|
||||
)
|
||||
resolved = resolve_reasoning(AgentConfig(thinking_budget_tokens=-5), provider, "gpt-4o-mini")
|
||||
assert resolved.thinking_budget_tokens == 0
|
||||
|
||||
|
||||
def test_max_survives_only_on_deepseek_chat() -> None:
|
||||
chat = DeepseekProvider(access_key_or_token="sk-test", reasoning_effort="max")
|
||||
assert resolve_reasoning(None, chat, "deepseek-v4-pro").effort == "max"
|
||||
|
||||
responses = DeepseekProvider(access_key_or_token="sk-test", convention="responses", reasoning_effort="max")
|
||||
assert resolve_reasoning(None, responses, "deepseek-v4-pro").effort == "xhigh"
|
||||
|
||||
anthropic = AnthropicProvider(access_key_or_token="sk-test", reasoning_effort="max")
|
||||
assert resolve_reasoning(None, anthropic, "claude-test").effort == "xhigh"
|
||||
|
||||
openai = OpenAIProvider(access_key_or_token="sk-test", reasoning_effort="max")
|
||||
assert resolve_reasoning(None, openai, "gpt-5.4").effort == "xhigh"
|
||||
|
||||
|
||||
def test_effort_levels_pass_through_unchanged() -> None:
|
||||
provider = _compat()
|
||||
for effort in ("none", "minimal", "low", "medium", "high", "xhigh"):
|
||||
resolved = resolve_reasoning(AgentConfig(reasoning_effort=effort), provider, "gpt-4o-mini")
|
||||
assert resolved.effort == effort
|
||||
@@ -0,0 +1,66 @@
|
||||
"""The routing decisions around a provider's API surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from plyngent.config.models import DeepseekProvider
|
||||
from plyngent.config.routing import (
|
||||
EffectiveProvider,
|
||||
deepseek_search_base_url,
|
||||
local_web_search,
|
||||
resolve_effective_provider,
|
||||
wants_web_search,
|
||||
)
|
||||
|
||||
SEARCH_TOOLS: list[dict[str, object]] = [{"type": "web_search"}]
|
||||
|
||||
|
||||
def _effective(
|
||||
*,
|
||||
convention: str = "",
|
||||
url: str = "",
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
sources: list[str] | None = None,
|
||||
) -> EffectiveProvider:
|
||||
provider = DeepseekProvider(access_key_or_token="sk-test", convention=convention, url=url)
|
||||
effective = resolve_effective_provider(provider, model="deepseek-flash")
|
||||
if tools is not None:
|
||||
effective = replace(effective, provider_tools=tools)
|
||||
if sources is not None:
|
||||
effective = replace(effective, search_sources=sources)
|
||||
return effective
|
||||
|
||||
|
||||
def test_a_deepseek_provider_asks_for_a_search_tool_like_openai_does() -> None:
|
||||
# ``provider_tools`` defaults to web_search for both presets; ``[]`` disables.
|
||||
assert wants_web_search(_effective()) is True
|
||||
assert wants_web_search(_effective(tools=[])) is False
|
||||
|
||||
|
||||
def test_chat_and_responses_conventions_use_the_local_tool() -> None:
|
||||
# Only the Anthropic surface hosts a search; the others ignore a hosted tool,
|
||||
# so the default web_search becomes plyngent's own tool there.
|
||||
assert local_web_search(_effective(convention="")) is True
|
||||
assert local_web_search(_effective(convention="responses")) is True
|
||||
assert local_web_search(_effective(convention="anthropic")) is False
|
||||
|
||||
|
||||
def test_without_provider_tools_there_is_no_search() -> None:
|
||||
assert local_web_search(_effective(convention="responses", tools=[])) is False
|
||||
|
||||
|
||||
def test_without_search_sources_there_is_no_local_tool() -> None:
|
||||
# ``search_sources = []`` leaves the tool out even though provider_tools ask
|
||||
# for a search (the hosted path on the Anthropic convention is unaffected).
|
||||
assert _effective(convention="responses", sources=[]).search_sources == []
|
||||
assert local_web_search(_effective(convention="responses", sources=[])) is False
|
||||
assert _effective(convention="responses").search_sources == ["deepseek", "bing"]
|
||||
|
||||
|
||||
def test_search_base_url_prefers_an_anthropic_endpoint() -> None:
|
||||
assert deepseek_search_base_url(_effective(convention="responses")) == "https://api.deepseek.com/anthropic"
|
||||
assert (
|
||||
deepseek_search_base_url(_effective(convention="responses", url="https://gateway.example/anthropic"))
|
||||
== "https://gateway.example/anthropic"
|
||||
)
|
||||
@@ -11,8 +11,10 @@ from plyngent.lmproto.anthropic.model import (
|
||||
AnthropicMessagesParam,
|
||||
AnthropicMessageStop,
|
||||
AnthropicResponseText,
|
||||
AnthropicThinkingConfig,
|
||||
AnthropicUsage,
|
||||
AnthropicUserMessage,
|
||||
AnthropicWebSearchToolResultContent,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +46,86 @@ def test_messages_param_encode_omits_defaults() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_decodes_a_server_side_search_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""DeepSeek's Anthropic compat answers a search turn with server tool blocks.
|
||||
|
||||
A live turn reads ``thinking, server_tool_use, web_search_tool_result, text``
|
||||
(the results carry an ``encrypted_content`` the API demands back unchanged);
|
||||
the response must decode, even though none of those blocks is a local call.
|
||||
"""
|
||||
body = {
|
||||
"id": "msg_search",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "deepseek-flash",
|
||||
"stop_reason": "end_turn",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me search.", "signature": "b032a4ad-87fe-42ce-801d-e33dd743d8b9"},
|
||||
{
|
||||
"type": "server_tool_use",
|
||||
"id": "call_00_joE6PEEC1otU8pvSe4zY3017",
|
||||
"name": "web_search",
|
||||
"input": {"query": "DeepSeek latest news"},
|
||||
"caller": {"type": "direct"},
|
||||
},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_00_joE6PEEC1otU8pvSe4zY3017",
|
||||
"content": [
|
||||
{
|
||||
"type": "web_search_result",
|
||||
"title": "Change Log | DeepSeek API Docs",
|
||||
"url": "https://api-docs.deepseek.com/updates/",
|
||||
"encrypted_content": "sd918tZXSvVp6j7ermvF4cl/lspbqHzths9SMQw6ziEw",
|
||||
}
|
||||
],
|
||||
},
|
||||
{"type": "text", "text": "DeepSeek shipped a new model."},
|
||||
],
|
||||
"usage": {"input_tokens": 16592, "output_tokens": 1419, "server_tool_use": {"web_search_requests": 4}},
|
||||
}
|
||||
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
content = msgspec.json.encode(body)
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
result = await client.messages(AnthropicMessagesParam(model="deepseek-flash", messages=[]))
|
||||
kinds = [block.__struct_config__.tag for block in result.content]
|
||||
assert kinds == ["thinking", "server_tool_use", "web_search_tool_result", "text"]
|
||||
search = result.content[2]
|
||||
assert isinstance(search, AnthropicWebSearchToolResultContent)
|
||||
assert search.tool_use_id == "call_00_joE6PEEC1otU8pvSe4zY3017"
|
||||
assert search.content[0]["encrypted_content"].startswith("sd918tZXSvVp")
|
||||
assert result.content[3].text == "DeepSeek shipped a new model."
|
||||
|
||||
|
||||
def test_messages_param_encodes_server_tool_dicts() -> None:
|
||||
"""A hosted tool goes on the wire as the server-tool dict the surface wants."""
|
||||
param = AnthropicMessagesParam(
|
||||
model="deepseek-flash",
|
||||
messages=[AnthropicUserMessage(content="hi")],
|
||||
tools=[{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}],
|
||||
)
|
||||
data = msgspec.json.decode(msgspec.json.encode(param))
|
||||
assert data["tools"] == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}]
|
||||
|
||||
|
||||
def test_messages_param_keeps_the_thinking_type_on_the_wire() -> None:
|
||||
"""``omit_defaults`` must not drop the block's ``type`` discriminator."""
|
||||
param = AnthropicMessagesParam(
|
||||
model="deepseek-flash",
|
||||
messages=[AnthropicUserMessage(content="hi")],
|
||||
thinking=AnthropicThinkingConfig(type="enabled", budget_tokens=16384),
|
||||
)
|
||||
data = msgspec.json.decode(msgspec.json.encode(param))
|
||||
assert data["thinking"] == {"type": "enabled", "budget_tokens": 16384}
|
||||
|
||||
|
||||
async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ def _sample_message_body() -> bytes:
|
||||
return msgspec.json.encode(
|
||||
AnthropicMessageResponse(
|
||||
id="msg_1",
|
||||
model="deepseek-v4-flash",
|
||||
model="deepseek-flash",
|
||||
content=[AnthropicResponseText(text="hello world")],
|
||||
stop_reason="end_turn",
|
||||
usage=AnthropicUsage(input_tokens=9, output_tokens=3),
|
||||
@@ -52,7 +52,7 @@ async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
result = await client.messages(AnthropicMessagesParam(model="deepseek-v4-flash", messages=[]))
|
||||
result = await client.messages(AnthropicMessagesParam(model="deepseek-flash", messages=[]))
|
||||
assert isinstance(result, AnthropicMessageResponse)
|
||||
assert result.content[0].text == "hello world"
|
||||
assert result.usage.output_tokens == 3
|
||||
@@ -78,7 +78,7 @@ async def test_client_messages_stream(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
stream = await client.messages(AnthropicMessagesParam(model="deepseek-v4-flash", messages=[]), stream=True)
|
||||
stream = await client.messages(AnthropicMessagesParam(model="deepseek-flash", messages=[]), stream=True)
|
||||
events = [event async for event in stream]
|
||||
assert len(events) == 2
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""DeepSeek server-side search backend (Anthropic-compatible endpoint)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from plyngent.lmproto.anthropic.config import AnthropicConfig
|
||||
from plyngent.lmproto.deepseek.anthropic.search import (
|
||||
DeepseekSearchClient,
|
||||
SearchHit,
|
||||
hits_from_content,
|
||||
)
|
||||
|
||||
|
||||
def test_hits_from_content_dedupes_and_skips_noise() -> None:
|
||||
content = [
|
||||
{"type": "thinking", "thinking": "search"},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_1",
|
||||
"content": [
|
||||
{"type": "web_search_result", "title": "First", "url": "https://a.example/x"},
|
||||
{"type": "web_search_result", "title": "First again", "url": "https://a.example/x"},
|
||||
{"type": "web_search_result", "url": "https://b.example/y"},
|
||||
{"type": "web_search_result", "title": "no url"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_2",
|
||||
"content": [{"type": "web_search_tool_result_error", "error_code": "max_uses_exceeded"}],
|
||||
},
|
||||
]
|
||||
hits = hits_from_content(content)
|
||||
assert hits == [
|
||||
SearchHit(title="First", url="https://a.example/x"),
|
||||
SearchHit(title="", url="https://b.example/y"),
|
||||
]
|
||||
assert hits_from_content("not a list") == []
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code: int, body: bytes) -> None:
|
||||
self.status_code = status_code
|
||||
self.content = body
|
||||
|
||||
|
||||
def _search_body() -> bytes:
|
||||
return msgspec.json.encode(
|
||||
{
|
||||
"id": "msg_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "server_tool_use",
|
||||
"id": "call_00_1",
|
||||
"name": "web_search",
|
||||
"input": {"query": "DeepSeek-V4"},
|
||||
},
|
||||
{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": "call_00_1",
|
||||
"content": [
|
||||
{
|
||||
"type": "web_search_result",
|
||||
"title": "DeepSeek-V4 preview",
|
||||
"url": "https://api-docs.deepseek.com/news/news260424/",
|
||||
"encrypted_content": "opaque",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"usage": {"input_tokens": 9932, "output_tokens": 377},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_sends_the_hosted_tool_and_reads_its_hits(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
|
||||
sent: dict[str, object] = {}
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
sent["path"] = path
|
||||
sent.update(kwargs)
|
||||
return _Resp(200, _search_body())
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
hits = await client.search("DeepSeek-V4", model="deepseek-flash")
|
||||
|
||||
assert hits == [SearchHit(title="DeepSeek-V4 preview", url="https://api-docs.deepseek.com/news/news260424/")]
|
||||
assert sent["path"] == "/messages"
|
||||
assert sent["stream"] is False
|
||||
data = sent["data"]
|
||||
assert isinstance(data, bytes)
|
||||
payload = msgspec.json.decode(data, type=dict[str, object])
|
||||
assert payload["tools"] == [{"type": "web_search_20250305", "name": "web_search", "max_uses": 3}]
|
||||
# A search turn stops as soon as it has searched; the answer is not needed.
|
||||
assert payload["max_tokens"] == 512
|
||||
assert payload["messages"] == [{"role": "user", "content": "DeepSeek-V4"}]
|
||||
headers = sent["headers"]
|
||||
assert isinstance(headers, dict)
|
||||
assert headers["x-api-key"] == "sk-test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_raises_with_the_endpoint_detail(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = DeepseekSearchClient(AnthropicConfig(api_key="sk-test", base_url="https://api.deepseek.com/anthropic"))
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
return _Resp(422, b'{"error": {"message": "tools[0]: unknown variant"}}')
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
with pytest.raises(RuntimeError, match="unknown variant"):
|
||||
_ = await client.search("DeepSeek-V4", model="deepseek-flash")
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for the DeepSeek chat-completions client's wire quirks.
|
||||
|
||||
``thinking`` (an explicit flag the API wants) and the ``reasoning_content``
|
||||
field a tool-call message must carry back in thinking mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
|
||||
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionTool,
|
||||
AssistantFunctionToolCall,
|
||||
ChatCompletionsParam,
|
||||
ToolChatMessage,
|
||||
UserChatMessage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, ReasoningEffort
|
||||
from plyngent.typedef import Unset
|
||||
|
||||
|
||||
async def _posted_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
effort: ReasoningEffort | Unset = UNSET,
|
||||
*,
|
||||
messages: list[AnyChatMessage] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run one streamed request and return the JSON body the client posted."""
|
||||
client = DeepseekOpenAIClient(OpenAIConfig(access_key_or_token="sk-test", base_url="https://api.deepseek.com"))
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
async def iter_lines(self) -> AsyncIterator[bytes]:
|
||||
yield b"data: [DONE]"
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def fake_post(path: str, **kwargs: object) -> _Resp:
|
||||
assert path == "/chat/completions"
|
||||
captured["data"] = kwargs.get("data")
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
param = ChatCompletionsParam(
|
||||
model="deepseek-v4-pro",
|
||||
messages=messages if messages is not None else [UserChatMessage(content="hi")],
|
||||
reasoning_effort=effort,
|
||||
)
|
||||
# Production dispatches the base param through the OpenAI-compatible surface
|
||||
# (not the DeepSeek-specific annotation), so mirror that here.
|
||||
cc: Any = client
|
||||
stream = cast("AsyncIterator[Any]", await cc.chat_completions(param, stream=True))
|
||||
async for _chunk in stream:
|
||||
pass
|
||||
data = captured["data"]
|
||||
assert isinstance(data, bytes)
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_enabled_without_configured_effort(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
body = await _posted_body(monkeypatch)
|
||||
assert body["thinking"] == {"type": "enabled"}
|
||||
assert "reasoning_effort" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_enabled_with_effort(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
body = await _posted_body(monkeypatch, "high")
|
||||
assert body["thinking"] == {"type": "enabled"}
|
||||
assert body["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thinking_disabled_for_none_effort(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
body = await _posted_body(monkeypatch, "none")
|
||||
assert body["thinking"] == {"type": "disabled"}
|
||||
assert body["reasoning_effort"] == "none"
|
||||
|
||||
|
||||
def _kept_call() -> AssistantChatMessage:
|
||||
"""The pairing carrier ``rollback_tail`` keeps for a call that ran."""
|
||||
return AssistantChatMessage(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="call_1",
|
||||
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_without_recorded_reasoning_gets_an_empty_field(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The API refuses a call whose ``reasoning_content`` never came back.
|
||||
|
||||
A rolled-back turn keeps its executed calls without one, and a synthetic
|
||||
``todo_list`` nag is forged the same way; the field only has to be present,
|
||||
so an empty string satisfies the check.
|
||||
"""
|
||||
body = await _posted_body(
|
||||
monkeypatch,
|
||||
messages=[
|
||||
UserChatMessage(content="read it"),
|
||||
_kept_call(),
|
||||
ToolChatMessage(content="file body", tool_call_id="call_1"),
|
||||
],
|
||||
)
|
||||
assistant = body["messages"][1]
|
||||
assert assistant["reasoning_content"] == ""
|
||||
assert assistant["tool_calls"][0]["id"] == "call_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recorded_reasoning_content_is_left_alone(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
call = _kept_call()
|
||||
messages: list[AnyChatMessage] = [
|
||||
UserChatMessage(content="read it"),
|
||||
msgspec.structs.replace(call, reasoning_content="Open the file first."),
|
||||
ToolChatMessage(content="file body", tool_call_id="call_1"),
|
||||
]
|
||||
body = await _posted_body(monkeypatch, messages=messages)
|
||||
assert body["messages"][1]["reasoning_content"] == "Open the file first."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_without_recorded_reasoning_gets_an_empty_field(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A round that called nothing needs the field too.
|
||||
|
||||
The rule is per round, not per call: the API answers "the
|
||||
``reasoning_content`` … must be passed back" for an answer round that never
|
||||
reasoned as well, and an empty string satisfies it.
|
||||
"""
|
||||
body = await _posted_body(
|
||||
monkeypatch,
|
||||
messages=[UserChatMessage(content="hi"), AssistantChatMessage(content="all done")],
|
||||
)
|
||||
assert body["messages"][1]["reasoning_content"] == ""
|
||||
assert body["messages"][1]["content"] == "all done"
|
||||
@@ -9,7 +9,7 @@ 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'{"id":"resp_1","object":"response","created_at":1,"model":"deepseek-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":[]}]}'
|
||||
@@ -40,7 +40,7 @@ async def test_client_responses_create(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
result = await client.responses(ResponsesCreateParam(model="deepseek-v4-flash", input="hi"))
|
||||
result = await client.responses(ResponsesCreateParam(model="deepseek-flash", input="hi"))
|
||||
assert isinstance(result, Response)
|
||||
assert response_output_text(result) == "hello from deepseek"
|
||||
|
||||
@@ -62,7 +62,7 @@ async def test_client_responses_stream_stops_at_terminal(monkeypatch: pytest.Mon
|
||||
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":[]}}'
|
||||
b'"model":"deepseek-flash","status":"completed","output":[]}}'
|
||||
)
|
||||
# Anything after the terminal event must not be consumed.
|
||||
yield b'data: {"type":"response.output_text.delta","delta":"ignored"}'
|
||||
@@ -77,7 +77,7 @@ async def test_client_responses_stream_stops_at_terminal(monkeypatch: pytest.Mon
|
||||
|
||||
monkeypatch.setattr(client.session, "post", fake_post)
|
||||
stream = await client.responses(
|
||||
ResponsesCreateParam(model="deepseek-v4-flash", input="hi"),
|
||||
ResponsesCreateParam(model="deepseek-flash", input="hi"),
|
||||
stream=True,
|
||||
)
|
||||
events = [event async for event in stream]
|
||||
|
||||
@@ -169,12 +169,12 @@ async def test_session_llm_remembered(store: MemoryStore) -> None:
|
||||
session = await store.create_session(
|
||||
name="llm",
|
||||
provider_name="deepseek",
|
||||
model="deepseek-v4-flash",
|
||||
model="deepseek-flash",
|
||||
)
|
||||
row = await store.get_session(session.sid)
|
||||
assert row is not None
|
||||
assert row.provider_name == "deepseek"
|
||||
assert row.model == "deepseek-v4-flash"
|
||||
assert row.model == "deepseek-flash"
|
||||
updated = await store.update_session_llm(session.sid, model="deepseek-v4-pro")
|
||||
assert updated.provider_name == "deepseek"
|
||||
assert updated.model == "deepseek-v4-pro"
|
||||
|
||||
@@ -60,7 +60,7 @@ def test_deepseek_openai_convention() -> None:
|
||||
client = create_client(provider)
|
||||
assert isinstance(client, DeepseekOpenAIClient)
|
||||
assert provider_to_openai_config(provider).base_url == "https://api.deepseek.com"
|
||||
assert "deepseek-v4-flash" in provider.models
|
||||
assert "deepseek-flash" in provider.models
|
||||
assert "deepseek-v4-pro" in provider.models
|
||||
|
||||
|
||||
@@ -129,13 +129,13 @@ def test_deepseek_model_level_anthropic_convention() -> None:
|
||||
access_key_or_token="sk-test",
|
||||
models={
|
||||
"claude-proxy": ModelConfig(convention="anthropic"),
|
||||
"deepseek-v4-flash": ModelConfig(),
|
||||
"deepseek-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")
|
||||
flash = create_client(provider, model="deepseek-flash")
|
||||
assert isinstance(flash, DeepseekOpenAIClient)
|
||||
|
||||
|
||||
@@ -145,11 +145,11 @@ def test_deepseek_model_level_convention_override() -> None:
|
||||
provider = DeepseekProvider(
|
||||
access_key_or_token="sk-test",
|
||||
models={
|
||||
"deepseek-v4-flash": ModelConfig(convention="responses"),
|
||||
"deepseek-flash": ModelConfig(convention="responses"),
|
||||
"deepseek-v4-pro": ModelConfig(),
|
||||
},
|
||||
)
|
||||
flash = create_client(provider, model="deepseek-v4-flash")
|
||||
flash = create_client(provider, model="deepseek-flash")
|
||||
assert isinstance(flash, DeepseekResponsesClient)
|
||||
assert flash.kind == "responses"
|
||||
pro = create_client(provider, model="deepseek-v4-pro")
|
||||
@@ -167,6 +167,29 @@ def test_anthropic_client_created() -> None:
|
||||
assert client.session.timeout == 30
|
||||
|
||||
|
||||
def test_anthropic_client_thinking_budget_from_config() -> None:
|
||||
from plyngent.config.reasoning import ReasoningConfig
|
||||
from plyngent.lmproto.anthropic import AnthropicClient
|
||||
|
||||
provider = AnthropicProvider(
|
||||
access_key_or_token="sk-test",
|
||||
thinking_budget_tokens=8000,
|
||||
models={"claude-test": ModelConfig(thinking_budget_tokens=16000)},
|
||||
)
|
||||
default_client = create_client(provider, model="claude-test")
|
||||
assert isinstance(default_client, AnthropicClient)
|
||||
assert default_client.thinking_budget_tokens == 16000
|
||||
|
||||
# An explicit (resolved) reasoning spec wins over the provider/model layers.
|
||||
client = create_client(
|
||||
provider,
|
||||
model="claude-test",
|
||||
reasoning=ReasoningConfig(effort="high", thinking_budget_tokens=4000),
|
||||
)
|
||||
assert isinstance(client, AnthropicClient)
|
||||
assert client.thinking_budget_tokens == 4000
|
||||
|
||||
|
||||
def test_model_level_preset_url_overrides_parent() -> None:
|
||||
provider = OpenAICompatibleProvider(
|
||||
access_key_or_token="sk-test",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Bing's RSS answers as a search source (parsing only — no network)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
|
||||
from plyngent.tools.net.bing import parse_bing_rss
|
||||
|
||||
DOCUMENT = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>必应:DeepSeek Harness</title>
|
||||
<item>
|
||||
<title>DeepSeek Harness v0.2</title>
|
||||
<link>https://pandaily.com/deepseek-harness-v0-2</link>
|
||||
<description>Desktop installers and a plugin manager.</description>
|
||||
<pubDate>Sat, 03 Oct 2026 12:06:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>No link here</title>
|
||||
<description>Dropped: a hit without a URL is nothing to read.</description>
|
||||
</item>
|
||||
<item>
|
||||
<title> Spaced title </title>
|
||||
<link> https://example.com/spaced </link>
|
||||
</item>
|
||||
</channel></rss>
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_bing_rss_maps_items_to_hits() -> None:
|
||||
hits = parse_bing_rss(DOCUMENT)
|
||||
assert hits == [
|
||||
SearchHit(
|
||||
title="DeepSeek Harness v0.2",
|
||||
url="https://pandaily.com/deepseek-harness-v0-2",
|
||||
snippet="Desktop installers and a plugin manager.",
|
||||
),
|
||||
SearchHit(title="Spaced title", url="https://example.com/spaced", snippet=""),
|
||||
]
|
||||
|
||||
|
||||
def test_parse_bing_rss_rejects_a_non_rss_body() -> None:
|
||||
with pytest.raises(RuntimeError, match="unparseable XML"):
|
||||
_ = parse_bing_rss("<!doctype html><html><body>rate limited</body></html>")
|
||||
@@ -65,6 +65,58 @@ def test_unwrap_stops_when_no_program_follows() -> None:
|
||||
assert scan.base == argv[0], argv
|
||||
|
||||
|
||||
def test_unwrap_value_free_flags_do_not_swallow_the_program() -> None:
|
||||
"""Only options that consume the next token may be listed as value-taking.
|
||||
|
||||
``exec -c`` (empty environment) and ``exec -l`` (login), ``command
|
||||
-p|-v|-V``, ``pkexec --disable-internal-agent`` and ``su -p`` are flags: a
|
||||
table entry for them ate the program behind them, so ``exec -c rm -rf /``
|
||||
hid ``rm`` from the denylist while ``exec -c git -c … init`` resolved to
|
||||
``init``.
|
||||
"""
|
||||
cases = [
|
||||
(["exec", "-c", "rm", "-rf", "/"], "rm"),
|
||||
(["exec", "-c", "git", "-c", "init.defaultBranch=main", "init"], "git"),
|
||||
(["exec", "-l", "python", "script.py"], "python"),
|
||||
(["command", "-v", "rm"], "rm"),
|
||||
(["command", "-p", "rm", "-rf", "/"], "rm"),
|
||||
(["pkexec", "--disable-internal-agent", "rm", "-rf", "/"], "rm"),
|
||||
(["su", "-p", "python", "script.py"], "python"),
|
||||
# Options that do take a value still consume it.
|
||||
(["exec", "-a", "name", "rm", "-rf", "/"], "rm"),
|
||||
(["xargs", "-e", "rm", "-rf", "/"], "rm"),
|
||||
]
|
||||
for argv, base in cases:
|
||||
scan = unwrap_command(argv)
|
||||
assert scan.base == base, argv
|
||||
assert scan.command[0] == base, argv
|
||||
|
||||
|
||||
def test_unwrap_collects_command_line_option_values() -> None:
|
||||
"""A wrapper option that takes a command line is reported, not just skipped.
|
||||
|
||||
``env -S "rm -rf /"`` hands over a command the review must see; the value is
|
||||
code, so it must not be treated as an argument (or as the program).
|
||||
"""
|
||||
scan = unwrap_command(["env", "-S", "rm -rf /"])
|
||||
assert scan.base == "env"
|
||||
assert [(span.wrapper, span.option, span.text) for span in scan.command_strings] == [("env", "-S", "rm -rf /")]
|
||||
assert scan.command_strings[0].label == "env -S"
|
||||
|
||||
for argv, option, text in (
|
||||
(["env", "--split-string=rm -rf /"], "--split-string", "rm -rf /"),
|
||||
(["env", "-Srm -rf /"], "-S", "rm -rf /"),
|
||||
(["su", "-c", "rm -rf /"], "-c", "rm -rf /"),
|
||||
(["su", "--session-command=rm -rf /"], "--session-command", "rm -rf /"),
|
||||
):
|
||||
spans = unwrap_command(argv).command_strings
|
||||
assert [(span.option, span.text) for span in spans] == [(option, text)], argv
|
||||
|
||||
# Plain wrapper options stay arguments: no command line to review.
|
||||
assert unwrap_command(["env", "-u", "FOO=1", "rm", "-rf", "/"]).command_strings == ()
|
||||
assert unwrap_command(["env", "FOO=1", "git", "-c", "x=y", "log"]).command_strings == ()
|
||||
|
||||
|
||||
def test_unwrap_empty_argv() -> None:
|
||||
scan = unwrap_command([])
|
||||
assert scan.base == ""
|
||||
|
||||
@@ -136,6 +136,34 @@ def test_wrappers_reach_the_interpreter() -> None:
|
||||
assert "$(command)" in reason
|
||||
|
||||
|
||||
def test_value_free_flags_do_not_hide_the_interpreter() -> None:
|
||||
"""``exec -c`` takes no value, so the interpreter behind it is still reviewed."""
|
||||
reason = classify_danger("run_argv", {"argv": ["exec", "-c", "python", "-c", "print(1)"]})
|
||||
assert reason is not None
|
||||
assert "interpreter 'python'" in reason
|
||||
assert "print(1)" in reason
|
||||
|
||||
|
||||
def test_wrapper_command_line_option_is_reviewed() -> None:
|
||||
"""`env -S` / `su -c` carry a command line, so they are confirmed like `-c`."""
|
||||
reason = classify_danger("run_argv", {"argv": ["env", "-S", "rm -rf /"]})
|
||||
assert reason is not None
|
||||
assert "env -S runs a command line" in reason
|
||||
assert " env -S:\n rm -rf /" in reason
|
||||
|
||||
# Plain wrapper options are not a review trigger.
|
||||
assert classify_danger("run_argv", {"argv": ["env", "-u", "FOO=1", "git", "status"]}) is None
|
||||
assert classify_danger("run_argv", {"argv": ["env", "FOO=1", "git", "-c", "x=y", "log"]}) is None
|
||||
|
||||
|
||||
def test_wrapper_command_line_value_needs_no_interpreter() -> None:
|
||||
"""The wrapper's value is reviewed even when no interpreter is resolved."""
|
||||
reason = classify_danger("run_argv", {"argv": ["env", "-S", "python -O script.py"]})
|
||||
assert reason is not None
|
||||
assert "env -S runs a command line" in reason
|
||||
assert "python -O script.py" in reason
|
||||
|
||||
|
||||
def test_only_the_interpreter_dash_c_becomes_a_placeholder() -> None:
|
||||
reason = classify_danger("run_argv", {"argv": ["ionice", "-c", "2", "python", "-c", "print(1)"]})
|
||||
assert reason is not None
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""The local ``web_search`` tool DeepSeek uses where its surface cannot host one."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent.tools import ToolTag
|
||||
from plyngent.lmproto.deepseek.anthropic.search import SearchHit
|
||||
from plyngent.tools.net.search import (
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
build_web_search_tool,
|
||||
search_in_order,
|
||||
)
|
||||
|
||||
|
||||
class _StubBackend:
|
||||
"""Stands in for a search source (raises, finds nothing, or answers)."""
|
||||
|
||||
def __init__(self, *, hits: list[SearchHit] | None = None, error: str | None = None) -> None:
|
||||
self.hits = hits or []
|
||||
self.error = error
|
||||
self.queries: list[str] = []
|
||||
|
||||
async def search(self, query: str) -> list[SearchHit]:
|
||||
self.queries.append(query)
|
||||
if self.error is not None:
|
||||
raise RuntimeError(self.error)
|
||||
return self.hits
|
||||
|
||||
|
||||
def _hits(*pairs: tuple[str, str]) -> list[SearchHit]:
|
||||
return [SearchHit(title=title, url=url) for title, url in pairs]
|
||||
|
||||
|
||||
def test_tool_definition_shape() -> None:
|
||||
definition = build_web_search_tool([_StubBackend()])
|
||||
assert definition.name == WEB_SEARCH_TOOL_NAME
|
||||
assert definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
|
||||
assert "fetch" in definition.description
|
||||
parameters = definition.parameters
|
||||
assert parameters["properties"]["query"] == {"type": "string"}
|
||||
assert parameters["required"] == ["query"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_returns_one_entry_per_hit() -> None:
|
||||
backend = _StubBackend(hits=_hits(("DeepSeek-V4 preview", "https://api-docs.deepseek.com/x")))
|
||||
definition = build_web_search_tool([backend])
|
||||
assert await definition.handler(query="DeepSeek-V4") == "- DeepSeek-V4 preview — https://api-docs.deepseek.com/x"
|
||||
assert backend.queries == ["DeepSeek-V4"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_prints_the_snippet_under_its_hit() -> None:
|
||||
backend = _StubBackend(hits=[SearchHit(title="T", url="https://example.com/1", snippet="One line of it.")])
|
||||
definition = build_web_search_tool([backend])
|
||||
assert await definition.handler(query="q") == "- T — https://example.com/1\n One line of it."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_first_source_that_answers_wins() -> None:
|
||||
failing = _StubBackend(error="HTTP 503")
|
||||
empty = _StubBackend()
|
||||
answering = _StubBackend(hits=_hits(("Found", "https://example.com/found")))
|
||||
hits, failures = await search_in_order([failing, empty, answering], "q")
|
||||
assert [hit.url for hit in hits] == ["https://example.com/found"]
|
||||
assert failures == ["_StubBackend: HTTP 503", "_StubBackend: no results"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_caps_the_hits_and_names_an_untitled_page() -> None:
|
||||
backend = _StubBackend(hits=_hits(*[("", f"https://example.com/{index}") for index in range(5)]))
|
||||
definition = build_web_search_tool([backend], max_hits=2)
|
||||
assert (await definition.handler(query="anything")).splitlines() == [
|
||||
"- (untitled) — https://example.com/0",
|
||||
"- (untitled) — https://example.com/1",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_reports_every_source_that_failed() -> None:
|
||||
definition = build_web_search_tool([_StubBackend(error="timeout"), _StubBackend()])
|
||||
message = await definition.handler(query="nothing matches this")
|
||||
assert "found nothing" in message
|
||||
assert "timeout" in message and "no results" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_without_sources_says_so() -> None:
|
||||
definition = build_web_search_tool([])
|
||||
assert "no source configured" in await definition.handler(query="q")
|
||||
|
||||
|
||||
def test_deepseek_backend_passes_the_model_through() -> None:
|
||||
from plyngent.tools.net.search import DeepseekSearchBackend
|
||||
|
||||
class _Client:
|
||||
def __init__(self) -> None:
|
||||
self.seen: list[tuple[str, str]] = []
|
||||
|
||||
async def search(self, query: str, *, model: str, **kwargs: object) -> list[SearchHit]:
|
||||
self.seen.append((query, model))
|
||||
return _hits(("T", "https://example.com"))
|
||||
|
||||
client: Any = _Client()
|
||||
backend = DeepseekSearchBackend(client, model="deepseek-flash")
|
||||
|
||||
async def run() -> list[SearchHit]:
|
||||
return list(await backend.search("q"))
|
||||
|
||||
import asyncio
|
||||
|
||||
hits = asyncio.run(run())
|
||||
assert [hit.url for hit in hits] == ["https://example.com"]
|
||||
assert client.seen == [("q", "deepseek-flash")]
|
||||
@@ -215,6 +215,30 @@ def test_command_denylist_policy_confirm_timeout_value(workspace: object) -> Non
|
||||
clear_policy_allowed_commands()
|
||||
|
||||
|
||||
def test_command_denylist_sees_the_program_behind_value_free_flags(workspace: object) -> None:
|
||||
"""`exec -c` / `command -p` take no value, so the program stays visible."""
|
||||
del workspace
|
||||
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
|
||||
|
||||
set_policy_confirm_hook(None)
|
||||
clear_policy_allowed_commands()
|
||||
set_command_denylist(None)
|
||||
try:
|
||||
for argv in (
|
||||
["exec", "-c", "rm", "-rf", "/"],
|
||||
["command", "-p", "rm", "-rf", "/"],
|
||||
["command", "-v", "rm"],
|
||||
):
|
||||
with pytest.raises(WorkspaceError, match="basename 'rm' is blocked"):
|
||||
check_command_allowed(list(argv))
|
||||
# ``init`` here is a git subcommand, not the denylisted program: the
|
||||
# command resolves to ``git`` and stays allowed.
|
||||
check_command_allowed(["exec", "-c", "git", "-c", "init.defaultBranch=main", "init"])
|
||||
finally:
|
||||
clear_policy_allowed_commands()
|
||||
set_command_denylist(None)
|
||||
|
||||
|
||||
def test_command_denylist_reaches_through_wrappers(workspace: object) -> None:
|
||||
del workspace
|
||||
from plyngent.tools.workspace import clear_policy_allowed_commands, set_policy_confirm_hook
|
||||
|
||||
Reference in New Issue
Block a user