18 Commits

Author SHA1 Message Date
NCBM 6a1160f1e3 docs: user install guide (uvx, uv tool, pipx, pip) and basic usage 2026-07-15 22:41:02 +08:00
NCBM 3f67f5634e ci/lint: ruff format display.py 2026-07-15 22:33:19 +08:00
NCBM 5d2f0c5539 core/cli: Rich markdown for assistant text at end of turn
Stream plain tokens; on TTY re-render with Rich. /markdown and PLYNGENT_PLAIN.
2026-07-15 22:21:18 +08:00
NCBM d267a5f869 ci/lint: apply ruff format and import sort fixes 2026-07-15 22:09:52 +08:00
NCBM 074a3313ce core/config: default OpenAI provider_tools to web_search
Omitted provider_tools enables web_search; use [] to disable hosted tools.
2026-07-15 21:43:39 +08:00
NCBM 84a83e0485 core/config: default OpenAI preset and model catalog
Missing preset becomes openai; omitted models seed gpt-5.4 / mini / nano.
2026-07-15 21:40:43 +08:00
NCBM 16992be743 core/config+agent: OpenAI provider_tools for hosted Responses tools
Merge opaque tool dicts (web_search, file_search, …) into POST /responses; local ToolRegistry unchanged.
2026-07-15 21:16:05 +08:00
NCBM 1f75cde3dd core/agent: clear basedpyright warnings on Responses adapter 2026-07-15 21:01:57 +08:00
NCBM 5dd102a016 core/agent: route OpenAI provider through Responses API
ResponsesChatClient adapts chat-shaped agent history/tools to POST /responses; compat and DeepSeek unchanged.
2026-07-15 20:59:47 +08:00
NCBM 38edc09854 core/lmproto: move Responses API into openai package
openai_compatible stays chat-completions-only; OpenAI provider uses lmproto.openai.OpenAIClient.
2026-07-15 20:52:17 +08:00
NCBM 3e20b1559c core/lmproto: add OpenAI Responses API models and client
POST/GET/DELETE /responses with stream events; helpers for output_text and function_call items.
2026-07-15 20:43:11 +08:00
NCBM 61582ffd7d scripts: install Windows Python via Wine uv and full ci check
Short WIN_PYTHON request under wine uv.exe; check/test/ci cover ruff, basedpyright, pytest.
2026-07-15 20:20:25 +08:00
NCBM fbba2c2a14 core/tools+test: make process and path checks Windows-portable
Use sys.executable for process tests; normalize denylist/glob paths; skip readline when missing.
2026-07-15 20:20:25 +08:00
NCBM e1547a242c scripts: portable Wine + uv + PDM helpers for Windows checks
Isolated project-view setup without touching the host venv; typings linked for basedpyright.
2026-07-15 20:01:54 +08:00
NCBM c466d09173 core/process: cross-platform PTY via backend and pywinpty
POSIX openpty/fork and Windows ConPTY; win32 marker dep plus typings stub.
2026-07-15 20:01:43 +08:00
NCBM 611147b0a5 core/config+cli: recover empty-models providers via models()
Keep empty models as recoverable; promote to ready after remote list or --model.
2026-07-15 17:32:10 +08:00
NCBM 6bd80cb915 core/cli: /models and remote catalog for model selection
Cache GET /models, merge with config ids for Tab and choose; free-form model ids allowed.
2026-07-15 17:32:04 +08:00
NCBM e1ea43cb8f core/lmproto: add OpenAI-compatible models() list API
Decode GET /models into ModelsResponse and return sorted unique ids.
2026-07-15 17:31:57 +08:00
47 changed files with 3549 additions and 313 deletions
+10 -5
View File
@@ -30,12 +30,15 @@ All protocol models use `msgspec.Struct` — not dataclasses, not Pydantic. Opti
```
lmproto/openai_compatible/ ← Base: model, config, client
lmproto/openai/ ← OpenAI platform: Responses + chat (extends base)
lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields
```
- **`openai_compatible/model.py`** — tagged chat messages (`SystemChatMessage`, `UserChatMessage`, …), tools, request/response, streaming chunks.
- **`openai_compatible/client.py`** — `BaseOpenAIClient` / `OpenAIClient` via `niquests` async + SSE.
- **`openai_compatible/client.py`** — `BaseOpenAIClient` / `OpenAICompatibleClient` via `niquests` async + SSE; `chat_completions`, `models` only (`OpenAIClient` is a compat alias).
- **`openai_compatible/config.py`** — `OpenAIConfig` (token + base URL).
- **`openai/model.py`** — OpenAI Responses API (`ResponsesCreateParam`, `Response`, function_call items, stream events).
- **`openai/client.py`** — platform `OpenAIClient`: chat completions + `responses` / `get_response` / `delete_response`.
- **DeepSeek** — `DeepseekOpenAIClient`; models add `reasoning_content`, `prefix`, `ThinkingOptions`. Config default model ids: `deepseek-v4-flash`, `deepseek-v4-pro`.
### Config (`config/`)
@@ -44,7 +47,7 @@ TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]
### Runtime (`runtime/`)
`create_client(provider)` maps config `Provider` → protocol client. OpenAI / openai-compatible / deepseek(openai convention) supported; anthropic and deepseek anthropic convention raise `ProviderNotSupportedError`.
`create_client(provider)` maps config `Provider` → protocol client. OpenAI → `lmproto.openai.OpenAIClient` (Responses-capable); openai-compatible / deepseek(openai convention) → chat-completions clients; anthropic and deepseek anthropic convention raise `ProviderNotSupportedError`.
### Memory (`memory/`)
@@ -52,7 +55,9 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
### Agent (`agent/`)
- **`ChatClient`** Protocol for `chat_completions`.
- **`ChatClient`** Protocol for `chat_completions` (agent history stays chat-shaped).
- **OpenAI Responses integration**: `ResponsesChatClient` adapts platform `OpenAIClient.responses` to `ChatClient`; selected automatically for `OpenAIProvider` in `create_client`. Compat/DeepSeek stay on chat completions.
- **Provider-side tools**: `OpenAIProvider.provider_tools` (list of dicts; default `[{type="web_search"}]` when omitted; `[]` disables) merged into Responses `tools` alongside local function tools; never executed by `ToolRegistry`.
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name.
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`.
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
@@ -67,8 +72,8 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
- **`workspace`**: path resolve under root; path substring denylist; command basename denylist; clearer deny messages.
- **`file`**: `read_file`, `write_file`, `listdir`, `tree`, `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`.
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (**Unix only**: `pty`+`fork`).
- PTY: structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)`; session limit/idle TTL/output budget; close SIGTERM→SIGKILL.
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `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=)`; 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.
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`.
- **`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.
+83 -11
View File
@@ -2,18 +2,64 @@
Single-user LLM chat and agent toolkit for the terminal.
Python **3.14+**, managed with [PDM](https://pdm-project.org/). OpenAI-compatible APIs (including DeepSeek OpenAI-compat), SQLite session memory, workspace-scoped file/process/VCS tools, and a readline REPL with slash commands.
Python **3.14+**. OpenAI-compatible APIs (including DeepSeek OpenAI-compat), OpenAI Responses with optional hosted tools, SQLite session memory, workspace-scoped file/process/VCS tools, and a readline REPL with slash commands.
Requires **Python 3.14+** on your `PATH` (or via [uv](https://docs.astral.sh/uv/) / [pipx](https://pipx.pypa.io/)).
## Install
```bash
# from a clone
pdm install
pdm run plyngent --help
### Quick try (`uvx`)
# or editable install into your environment
pdm install
# entry point: plyngent
No permanent install — runs the published package in a temporary environment:
```bash
uvx plyngent --help
uvx plyngent chat
```
### User tool install
Keep `plyngent` on your PATH as a managed tool:
```bash
# uv (recommended)
uv tool install plyngent
plyngent --help
# pipx
pipx install plyngent
plyngent --help
```
Upgrade later:
```bash
uv tool upgrade plyngent
# or: pipx upgrade plyngent
```
### pip (venv or user)
```bash
python3.14 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -U pip
pip install plyngent
plyngent --help
```
Or user install (if you accept that layout):
```bash
pip install --user plyngent
```
### From a git clone (development)
```bash
pdm install # first time
pdm sync # after pull
pdm run plyngent --help
```
Dev checks:
@@ -25,6 +71,30 @@ pdm run ruff format .
pdm run pytest
```
## Basic usage
```bash
# 1) Create / open config
plyngent config path
plyngent config edit # needs $EDITOR
# Minimal provider (OpenAI platform — Responses API; preset defaults to openai):
# [providers.oai]
# access_key_or_token = "sk-..."
# # models default: gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
# # provider_tools default: web_search (use provider_tools = [] to disable)
# 2) Chat
plyngent chat
plyngent chat --provider oai --model gpt-5.4-mini
plyngent chat -p "Summarize this repo" --provider oai --model gpt-5.4-mini --no-stream
# 3) List providers from config
plyngent providers
```
In the REPL: type normally, use `/help` for slash commands, `"""` … `"""` for multiline, `/markdown` for Rich rendering, `/quit` to leave.
## Configure
Default config path (platformdirs):
@@ -58,7 +128,7 @@ confirm_destructive = true
max_context_tokens = 200000
```
Supported provider presets today: `openai`, `openai-compatible`, `deepseek` (OpenAI convention; default models `deepseek-v4-flash` and `deepseek-v4-pro` if `models` is omitted). Anthropic presets are modeled in config but not wired in the runtime client yet.
Supported provider presets today: `openai` (default if `preset` is omitted; default models `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` when `models` is omitted), `openai-compatible`, `deepseek` (OpenAI convention; default models `deepseek-v4-flash` and `deepseek-v4-pro` if `models` is omitted). Anthropic presets are modeled in config but not wired in the runtime client yet.
If `[database]` is omitted (or SQLite `url` is empty/`":memory:"`), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux).
@@ -121,9 +191,11 @@ Type `/help` in the REPL for the live list. Common ones:
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
| `/compact` | Soft-compact + model summary into a **new** session |
| `/stream` `/verbose` `/tools` `/rounds` | Toggles and limits |
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/provider` `/model` | Switch without restarting |
| `/models` | List config + remote `GET /models` (`--refresh` bypasses cache) |
| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
| `/quit` | Leave the REPL |
@@ -137,7 +209,7 @@ User messages are saved immediately. On API error or Ctrl+C, partial assistant/t
## Tools (when enabled)
Default registry: file ops, `run_command` / PTY (Unix), read-only VCS (git), and human prompts (`ask_user` / `choose_user` / `form_user`).
Default registry: file ops, `run_command` / PTY (POSIX openpty; Windows ConPTY via pywinpty), read-only VCS (git), and human prompts (`ask_user` / `choose_user` / `form_user`).
Safety defaults:
+11 -1
View File
@@ -31,11 +31,21 @@ access_key_or_token = "sk-replace-me"
[providers.openai_compat.models]
"gpt-4o-mini" = { text = true, cost_factor = 1.0 }
# --- OpenAI preset (same client shape; tag is "openai") ---
# --- OpenAI platform (Responses API; tag is "openai") ---
# If ``preset`` is omitted, it defaults to "openai".
# If ``models`` is omitted, defaults: gpt-5.4, gpt-5.4-mini, gpt-5.4-nano.
# [providers.openai]
# preset = "openai"
# access_key_or_token = "sk-replace-me"
# url = "https://api.openai.com/v1"
# # 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.
# provider_tools = [
# { type = "web_search" },
# # { type = "file_search", vector_store_ids = ["vs_xxx"] },
# # { type = "code_interpreter", container = { type = "auto" } },
# ]
#
# [providers.openai.models]
# "gpt-4o-mini" = { text = true }
Generated
+150 -101
View File
@@ -5,7 +5,7 @@
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
content_hash = "sha256:cd5131c309485626148d1933a2a557a9cb6a41038b980bc26a453def4c38032c"
content_hash = "sha256:b849e1647ce0ec47b61889ae0e66863acce020cd1f2e3deaeb9e2e92becf9da5"
[[metadata.targets]]
requires_python = "~=3.14"
@@ -52,45 +52,39 @@ files = [
[[package]]
name = "charset-normalizer"
version = "3.4.7"
version = "3.4.9"
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.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
{file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
{file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
{file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"},
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"},
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"},
{file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"},
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"},
{file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"},
{file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"},
]
[[package]]
@@ -231,6 +225,31 @@ files = [
{file = "jh2-5.0.13.tar.gz", hash = "sha256:f8c78cffb3a35c4410513c3eb7989de36028c84277c04f07c97909dd94c23a75"},
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
requires_python = ">=3.10"
summary = "Python port of markdown-it. Markdown parsing, done right!"
groups = ["default"]
dependencies = [
"mdurl~=0.1",
]
files = [
{file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"},
{file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"},
]
[[package]]
name = "mdurl"
version = "0.1.2"
requires_python = ">=3.7"
summary = "Markdown URL utilities"
groups = ["default"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "msgspec"
version = "0.21.1"
@@ -259,7 +278,7 @@ files = [
[[package]]
name = "niquests"
version = "3.20.0"
version = "3.20.1"
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"]
@@ -269,8 +288,8 @@ dependencies = [
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
]
files = [
{file = "niquests-3.20.0-py3-none-any.whl", hash = "sha256:86281a62314d7395f049d748480589e273dc1f429953afbac377fff142860429"},
{file = "niquests-3.20.0.tar.gz", hash = "sha256:dba85ce23ac5052f0a1e1c1cf1c017511a993f906686e8b6c84e0b54818fd1dc"},
{file = "niquests-3.20.1-py3-none-any.whl", hash = "sha256:71062880739c94d67aead1f8b7972baad3236b97a55a490493729789d1639fc4"},
{file = "niquests-3.20.1.tar.gz", hash = "sha256:55951812cf997963bfef8e4f797ff0460ca571e53ec184039b010e12152c0e41"},
]
[[package]]
@@ -332,7 +351,7 @@ name = "pygments"
version = "2.20.0"
requires_python = ">=3.9"
summary = "Pygments is a syntax highlighting package written in Python."
groups = ["dev"]
groups = ["default", "dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
@@ -374,76 +393,106 @@ files = [
{file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"},
]
[[package]]
name = "pywinpty"
version = "3.0.5"
requires_python = ">=3.10"
summary = "Pseudo terminal support for Windows from Python."
groups = ["default"]
marker = "sys_platform == \"win32\""
files = [
{file = "pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac"},
{file = "pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc"},
{file = "pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d"},
{file = "pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690"},
{file = "pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b"},
]
[[package]]
name = "qh3"
version = "1.9.2"
version = "1.9.4"
requires_python = ">=3.7"
summary = "A lightway 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-1.9.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e38d88f41c6855184a53a1e583d56ae5163b8b70c7e2dc273889ec303cc27585"},
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3beebd98b08bd2fe951ae83b6e52dee3821158ab21ba05f15fbdb401bd6e19f9"},
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6afe20084235ff3c20256c952059277b5a0f5144d63d1892d1dfb5d1b635faef"},
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c8f2fdf124acef06306ee50421ee80d37dbc7dfd41ddcec2bb63f3e2508bd74"},
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:adcb00af8c4fddbb266ae4155ec5932dcb0a27dc14d7f7a27fad6a6dce9efd5e"},
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcbf94d8ab4223873f2fc2f7a0992ee0dd557f0ab5bce1ca12314e60a1d5cd39"},
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13b458a440f91d5336dd65f4a624b7eeac0ce1b2a8d02e250df32470606c6775"},
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7b0429d9c5d892633ee822a428c840201a2ef53a16cec4d4aeb1ae08146cae9"},
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:d4ab482b9f12abb5fc6fc67a214431037d094b38673efd6d0df5d64338dd5351"},
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ab254e2289bfc949b0177aa7739ac963f54355716aee39966120ca71d7315aeb"},
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9a6aa7146a62c88fbc8adfa6dcae435670a8487e5f3bb00f92a29060cd630102"},
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2710e359d9320014a33149853cbc8c4bd9ca83181bb7a896c7704a1e429c27c4"},
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:4d136fa78171ffc094e06c25361fe00482accdb406a442ce93e8121314880659"},
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a48cc6b4d21ef85071ef2f20ba27d7412827ff8385baa19d14b0c58e44fdbc57"},
{file = "qh3-1.9.2-cp314-cp314t-win32.whl", hash = "sha256:c4dc40c1ac0f31bfc0dadeaf02578460c7456498f549bb6ae742883f90286243"},
{file = "qh3-1.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:19893cfe25a749c363e81415dcdb3b027707e71a09b0e1d4f1e3098d7e5975d3"},
{file = "qh3-1.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:75b587b26a8cdeb1c715e5d2ec08d4987be8e02bb76a6b24a4075293ade0bd3d"},
{file = "qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:aabb7c86f49c668ab5bfb297c5da18755a3ca9cf362f8ce73b86f55f43f662fc"},
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e632d420d88e30e4e3fc9f5457c362e964e93e91f9c90d148b68b0d4b0dc06"},
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5260ff1c42f7221fb7e92bc9c55a009341c2851f2dbb28475ddcf0a32c2226a"},
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:304e81b4cf3f97610a1aceaa1aec90a97f0c700071efcc9d6a3b86da0b57539d"},
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:aa20581d3daf8f80b62f42a10c1f69c12f3b6310f141a2e2f4259de588e570fe"},
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd3a3d7d737644a4d24117613e89220cd5223cb1e0c8281a5a65a97ebaf1efea"},
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82edac5bc2eea0881baa142d93e80469347875909acc19ce5e4236b50c465f8e"},
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b18b9d2f892bb95f7f3d3428bb4166f974c9a2e26fcf91b33dda9b4285e7928"},
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:06f2a10a01b71137fef8bb7b4115f1016d6e4a7f056ce41517ff157e808bf444"},
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e15519dc326315c52ab708028bf72aa9162eb3c1bd0ba130dc6d371b000639d2"},
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:7e04bf8bbbfb8a9f363fd38834aa9c52fcc0164182c72ea10cf30a3e2df066c9"},
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:e3b0b0d17268d57b808a658db7fd7c5e62561ad6d462949d8466c92b22551067"},
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:efe5ad0a9df71a9a5881615b0c0888767ad3a7cab05e54ba98db52d89d0b796a"},
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:742dd7c5e2207db27d24036ca599cad056bd5e58a5ce1b43f0c424df149e17d3"},
{file = "qh3-1.9.2-cp37-abi3-win32.whl", hash = "sha256:8d1f185a76578929b0de3dcef135c0cc21d4bff0ef00bd75127d244ba3426125"},
{file = "qh3-1.9.2-cp37-abi3-win_amd64.whl", hash = "sha256:643e8aa05519dddaf20159d1c13c15473de7649622a1c438968ce0f0da973951"},
{file = "qh3-1.9.2-cp37-abi3-win_arm64.whl", hash = "sha256:87bace13780b3c2238db1dd31deb10152203d5b360ca6515c78b853796245606"},
{file = "qh3-1.9.2.tar.gz", hash = "sha256:c6c92f63c2ec292256b5a5ed9345c42344bdaca2e55ec795623987a563aea19c"},
{file = "qh3-1.9.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:100368855b0e103774438aaf8b38c711fb51b79f18e3b9dc873ea573648bcc3d"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9d45e544e0a556380bd2a92af180ee98d32bca6cf622f28350ebc000611bdf"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43f80d662078c26563a1aac32a4df22649473d996bf2422e073c8aa55b1c837c"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:551f60d487d849d6f2313dda94e54ebb16b1a29acb93676540ae790a56308c24"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3755d777ff35561bc719f571ebc4b91aaa8a945c210e50b2576bf2cc69cb511f"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04b64bb18e10d8f482bea1ef43e5da3448e2d5ba78bb0c735da5b9e02d7dfb0e"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b47597daa4ae9195f81a35822217a395d3277f01a393d9c2bb3796a6b458b22c"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9bb9012bc02c44da6af1b931b6082588519685fad8a1f866c341f749394f53f"},
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9c7f3781ec022dd958b5eb0e5cb2e2a54e207a3d8837fb7290bc239912648fd7"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:633a24ab2db76d64881876c9d0f1b226a47d892bd7d78e61ea2c788ab59570af"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:f2ff36b84a4ab9b68806af1f6985fa3b47e29695effe766a3e70c21efac8157c"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2100697d736dbdfd1d3f0266c9fcadea6230b66ed59e3e7f8c9a9d1a6eec0c65"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:9ce179efaf97f927f01fe158619f5624d8d874c7161b7c497f06897cdde210d5"},
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:476ee4fcdbd3454eb0eb4dee5678294f06c08ca8e8b83160e2d0ae3c4ae34603"},
{file = "qh3-1.9.4-cp314-cp314t-win32.whl", hash = "sha256:fe9df8136bf358d45a2757287523abaeb208fcbe2c1e34664562eb2d8f64dbdb"},
{file = "qh3-1.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1fbdf53feb1732faf6c1cb34f2e1cbe4b543054db7b4d19d8752f1162e9370be"},
{file = "qh3-1.9.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e03ee1e2157a31c69415c3dbfbfe64e02469499ca46e968d13d8a7b289bf520a"},
{file = "qh3-1.9.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:26f7c8ed1a7ac0e25b654f030a4f472830c80890cc284dc40a31c17ecc21fbf0"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57b3dfe5dd2da2084adea63dba0bf587a4096ebf9207339187c90139d1f460d"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fa1a3debb5199da3c7f45b0ca6efa55174dfd6229370e774099073143ebe9e9"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dc9ff40d3a06f27b47d93417666f03ba6fd6b9585d9711ebbddf84fa4b7c50f"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:010b132c33719af3df4c1d6f569e52a2a20046c55a31c5532e741019c8c53868"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:124045b0c04c05afc038a239d7717019b4c4b9d0cb967040b4331f4d588eff7d"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a94a1726bf46a451a0295d68fafde28794e83ab6fda7457cbd9309c38151de9"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b361e627b6a529ecb231c1d5f3f2abd6d1952b0d7cc02c7718d9657d55dae37"},
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:43b6cfb2240836e3e54bbe61ad1b8fa5716516ff5359131a2421c55676a24f26"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e6db3def610e9c2d2f3602d3e9c8b2156273225f6eedf74a4749d3bc21af8167"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:22d5879dc93d2c7ee14cc5933b12c7780d9c7705dff4553becea152fcb767d99"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:2c2b2005fe6c605d61cac05ec2b3c019cc546184fa43444f3d1e3d22ed020ab2"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:e991b0f20b172fea466d0ffd499a85bf2f26decdeeda537275c72b5f7b26ccfe"},
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c13775ed27a88b297f6a1aa05ffb286879453f99e1e53e0af2b22e7ed8f92ebf"},
{file = "qh3-1.9.4-cp37-abi3-win32.whl", hash = "sha256:952abe1be90c0114993886792a8a5ec2211f8391df41227f5b7a5a02ff71bc26"},
{file = "qh3-1.9.4-cp37-abi3-win_amd64.whl", hash = "sha256:5543cb40a48d401f3fa4ec89398e3a213634f195716a9d06d70417c05edbbb32"},
{file = "qh3-1.9.4-cp37-abi3-win_arm64.whl", hash = "sha256:d0bd73b3e73f4f80d2430d410b532222ecf57e9d4fe8600b7683c775cbff9479"},
{file = "qh3-1.9.4.tar.gz", hash = "sha256:bd2ea9baf19656c544a48a56a195f2ac257cd973b566f5f2998fa3b7446281a1"},
]
[[package]]
name = "rich"
version = "15.0.0"
requires_python = ">=3.9.0"
summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
groups = ["default"]
dependencies = [
"markdown-it-py>=2.2.0",
"pygments<3.0.0,>=2.13.0",
]
files = [
{file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"},
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
]
[[package]]
name = "ruff"
version = "0.15.20"
version = "0.15.21"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["dev"]
files = [
{file = "ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078"},
{file = "ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b"},
{file = "ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632"},
{file = "ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd"},
{file = "ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b"},
{file = "ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267"},
{file = "ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c"},
{file = "ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae"},
{file = "ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b"},
{file = "ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487"},
{file = "ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3"},
{file = "ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053"},
{file = "ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4"},
{file = "ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460"},
{file = "ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21"},
{file = "ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415"},
{file = "ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca"},
{file = "ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566"},
{file = "ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d"},
{file = "ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd"},
{file = "ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67"},
{file = "ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8"},
{file = "ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233"},
{file = "ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f"},
{file = "ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd"},
{file = "ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3"},
{file = "ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8"},
{file = "ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500"},
]
[[package]]
@@ -500,7 +549,7 @@ files = [
[[package]]
name = "urllib3-future"
version = "2.22.900"
version = "2.22.901"
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"]
@@ -510,18 +559,18 @@ dependencies = [
"qh3<2.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.22.900-py3-none-any.whl", hash = "sha256:803976fe865f03d61d5ddc17dbdbac2bacc08202aad303f282cc4af319ddbfd2"},
{file = "urllib3_future-2.22.900.tar.gz", hash = "sha256:3161900c00cf41db90dd3d938c1d14960faab475dd6ca0bf5ecd06112327c714"},
{file = "urllib3_future-2.22.901-py3-none-any.whl", hash = "sha256:dc2d4572ba612be69e3f6db87d1f469b7d08f107e80fd8749081d7e6a5a2fe10"},
{file = "urllib3_future-2.22.901.tar.gz", hash = "sha256:fbece0ff51299a213c926897ffbed61d820ab5ef2ba81cece361c049fe2f51a9"},
]
[[package]]
name = "wassima"
version = "2.1.1"
version = "2.1.2"
requires_python = ">=3.7"
summary = "Access your OS root certificates with utmost ease"
groups = ["default"]
marker = "sys_platform != \"emscripten\""
files = [
{file = "wassima-2.1.1-py3-none-any.whl", hash = "sha256:ab0f12c091ff697f111eea5f925aafd83736909a1c9c764a8bbf874b2e4d4a42"},
{file = "wassima-2.1.1.tar.gz", hash = "sha256:9c6ad4aa3cfbe91fd75f9eae315ba563bbc7d9d2479aef0c288fa7f1ca3b0c53"},
{file = "wassima-2.1.2-py3-none-any.whl", hash = "sha256:9a5779e8ade39960973c6cc17e095cc998d6345ab3dbdd1ec2d7fde8c356fb82"},
{file = "wassima-2.1.2.tar.gz", hash = "sha256:f74b5441151728c54118ece6d747cfe92b2c595a3d062a1955f42a2bc894cd10"},
]
+13 -1
View File
@@ -14,7 +14,9 @@ dependencies = [
"sqlalchemy>=2.0.51",
"aiosqlite>=0.22.1",
"click>=8.4.2",
"awaitlet>=0.0.1"
"awaitlet>=0.0.1",
"pywinpty>=2.0.0; sys_platform == \"win32\"",
"rich>=13"
]
requires-python = ">=3.14,<4"
readme = "README.md"
@@ -38,6 +40,8 @@ build-backend = "pdm.backend"
[tool.basedpyright]
typeCheckingMode = "recommended"
strictGenericNarrowing = true
# winpty is Windows-only (pywinpty); stubs live under typings/ (pyright default stubPath).
reportMissingModuleSource = "hint"
reportAny = "hint"
reportExplicitAny = "hint"
reportImplicitStringConcatenation = "hint"
@@ -60,6 +64,10 @@ reportUnusedParameter = "none"
reportMissingImports = "none"
reportMissingTypeStubs = "none"
[[tool.basedpyright.executionEnvironments]]
root = "scripts"
reportUnusedCallResult = "none"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
@@ -163,3 +171,7 @@ allowed-confusables = [
"TRY003", # long exception messages in tests
"FBT", # boolean positional args in helpers
]
"scripts/**" = [
"T201", # CLI scripts print status
"PLR0911", # command dispatch returns
]
+314
View File
@@ -0,0 +1,314 @@
"""Portable Wine + Windows uv/Python paths for plyngent.
All locations are derived from this file's path and env overrides.
Does not mutate the host project virtualenv.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
from urllib.request import urlretrieve
# Defaults are version tags only — not machine paths.
# Short uv request (e.g. ``3.14``); Windows uv.exe installs the matching windows build.
DEFAULT_WIN_PYTHON = "3.14"
DEFAULT_WIN_UV_VERSION = "0.11.28"
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
# (e.g. kill(sig) required); pyright default stubPath is ``typings/``.
VIEW_LINKS = ("src", "tests", "scripts", "typings", "README.md", "LICENSE", "CLAUDE.md", "doc")
VIEW_COPIES = ("pyproject.toml", "pdm.lock")
def repo_root() -> Path:
"""Repository root (parent of ``scripts/``)."""
env = os.environ.get("PLYNGENT_SRC")
if env:
return Path(env).expanduser().resolve()
return Path(__file__).resolve().parent.parent
def wine_base() -> Path:
"""Isolated work directory (never the project tree)."""
env = os.environ.get("PLYNGENT_WINE_BASE")
if env:
return Path(env).expanduser().resolve()
return Path(tempfile.gettempdir()).resolve() / "plyngent-wine"
def to_wine_z(path: Path) -> str:
"""Map an absolute POSIX path to Wine ``Z:\\...`` form.
On typical Linux Wine installs, ``Z:`` is the host root ``/``.
"""
resolved = path.expanduser().resolve()
if not resolved.is_absolute():
msg = f"path must be absolute for Wine Z: mapping: {path}"
raise ValueError(msg)
# Z:\tmp\foo
return "Z:" + str(resolved).replace("/", "\\")
def to_wine_c(prefix: Path, host_path: Path) -> str:
"""Map a path under the Wine prefix drive_c to ``C:\\...``."""
resolved = host_path.expanduser().resolve()
drive_c = (prefix / "drive_c").resolve()
try:
rel = resolved.relative_to(drive_c)
except ValueError as exc:
msg = f"{resolved} is not under {drive_c}"
raise ValueError(msg) from exc
return "C:\\" + str(rel).replace("/", "\\")
@dataclass(frozen=True, slots=True)
class WineLayout:
"""All host and Wine paths for one isolated environment."""
src: Path
base: Path
prefix: Path
view: Path
bin_dir: Path
uv_python: Path
uv_cache: Path
uv_tools: Path
pdm_bootstrap: Path
win_python: str
win_uv_version: str
@classmethod
def from_env(cls) -> WineLayout:
src = repo_root()
base = wine_base()
prefix_env = os.environ.get("PLYNGENT_WINEPREFIX")
prefix = Path(prefix_env).expanduser().resolve() if prefix_env else base / "wineprefix"
return cls(
src=src,
base=base,
prefix=prefix,
view=base / "project-view",
bin_dir=base / "bin",
uv_python=base / "uv-python",
uv_cache=base / "uv-cache",
uv_tools=base / "uv-tools",
pdm_bootstrap=base / "venv-pdm",
win_python=os.environ.get("WIN_PYTHON", DEFAULT_WIN_PYTHON),
win_uv_version=os.environ.get("WIN_UV_VERSION", DEFAULT_WIN_UV_VERSION),
)
def find_win_python_host(self) -> Path | None:
"""Locate a Windows ``python.exe`` under the uv install dir."""
if not self.uv_python.is_dir():
return None
# Prefer newest full windows install (cpython-*-windows-*/python.exe).
candidates = sorted(
self.uv_python.glob("cpython-*-windows-*/python.exe"),
key=lambda p: p.stat().st_mtime,
)
if candidates:
return candidates[-1]
# Fallback: any python.exe one level down.
for path in sorted(self.uv_python.glob("*/python.exe")):
return path
return None
@property
def win_python_host(self) -> Path:
found = self.find_win_python_host()
if found is not None:
return found
# Placeholder path used in error messages before install.
return self.uv_python / f"cpython-{self.win_python}-windows-x86_64-none" / "python.exe"
@property
def win_python_z(self) -> str:
return to_wine_z(self.win_python_host)
@property
def uv_exe(self) -> Path:
return self.bin_dir / "uv.exe"
@property
def uvx_exe(self) -> Path:
return self.bin_dir / "uvx.exe"
@property
def pdm_exe(self) -> Path:
return self.pdm_bootstrap / "Scripts" / "pdm.exe"
@property
def pdm_python_z(self) -> str:
return to_wine_z(self.pdm_bootstrap / "Scripts" / "python.exe")
def export_env(self) -> dict[str, str]:
"""Environment for Wine/uv subprocesses (no host VIRTUAL_ENV)."""
env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
env["WINEPREFIX"] = str(self.prefix)
env["WINEDEBUG"] = os.environ.get("PLYNGENT_WINEDEBUG", "-all")
env["UV_CACHE_DIR"] = to_wine_z(self.uv_cache)
env["UV_PYTHON_INSTALL_DIR"] = to_wine_z(self.uv_python)
env["UV_TOOL_DIR"] = to_wine_z(self.uv_tools)
env["PDM_IGNORE_SAVED_PYTHON"] = "1"
env["PLYNGENT_SRC"] = str(self.src)
env["PLYNGENT_WINE_BASE"] = str(self.base)
return env
def require_cmds(*names: str) -> None:
missing = [n for n in names if shutil.which(n) is None]
if missing:
msg = f"missing command(s): {', '.join(missing)}"
raise RuntimeError(msg)
def run(cmd: list[str], *, env: dict[str, str], cwd: Path | None = None) -> int:
_ = sys.stderr.write("+ " + " ".join(cmd) + "\n")
return subprocess.call(cmd, env=env, cwd=cwd)
def ensure_prefix(layout: WineLayout, env: dict[str, str]) -> None:
for path in (layout.uv_cache, layout.uv_python, layout.uv_tools, layout.bin_dir):
path.mkdir(parents=True, exist_ok=True)
if not (layout.prefix / "drive_c").is_dir():
layout.prefix.mkdir(parents=True, exist_ok=True)
_ = run(["wineboot", "-i"], env=env)
def ensure_windows_uv(layout: WineLayout) -> None:
if layout.uv_exe.is_file():
return
print(f"Fetching Windows uv {layout.win_uv_version} ...", file=sys.stderr)
layout.bin_dir.mkdir(parents=True, exist_ok=True)
url = f"https://github.com/astral-sh/uv/releases/download/{layout.win_uv_version}/uv-x86_64-pc-windows-msvc.zip"
zip_path = layout.bin_dir / "uv.zip"
_ = urlretrieve(url, zip_path)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(layout.bin_dir)
if not layout.uv_exe.is_file():
msg = f"uv.exe missing after extract: {layout.uv_exe}"
raise RuntimeError(msg)
def ensure_windows_python(layout: WineLayout, env: dict[str, str]) -> None:
"""Install Windows CPython via Wine ``uv.exe`` using a short version request."""
if layout.find_win_python_host() is not None:
return
if not layout.uv_exe.is_file():
msg = "Windows uv.exe missing; call ensure_windows_uv first"
raise RuntimeError(msg)
print(
f"Installing Windows CPython via Wine uv ({layout.win_python}) ...",
file=sys.stderr,
)
code = run(
["wine", str(layout.uv_exe), "python", "install", layout.win_python],
env=env,
)
if code != 0:
msg = f"wine uv python install failed with exit {code}"
raise RuntimeError(msg)
found = layout.find_win_python_host()
if found is None or not found.is_file():
msg = f"Windows python.exe missing after install under {layout.uv_python}"
raise RuntimeError(msg)
def ensure_pdm_bootstrap(layout: WineLayout, env: dict[str, str]) -> None:
if layout.pdm_exe.is_file():
return
print(f"Creating Windows bootstrap venv + pdm at {layout.pdm_bootstrap} ...", file=sys.stderr)
venv_z = to_wine_z(layout.pdm_bootstrap)
code = run(
["wine", str(layout.uv_exe), "venv", "--python", layout.win_python_z, venv_z],
env=env,
)
if code != 0:
msg = f"uv venv failed with exit {code}"
raise RuntimeError(msg)
code = run(
[
"wine",
str(layout.uv_exe),
"pip",
"install",
"--python",
layout.pdm_python_z,
"pdm",
],
env=env,
)
if code != 0:
msg = f"uv pip install pdm failed with exit {code}"
raise RuntimeError(msg)
def refresh_project_view(layout: WineLayout) -> None:
"""Symlink immutable sources; copy packaging files only."""
if layout.view.exists():
shutil.rmtree(layout.view)
layout.view.mkdir(parents=True)
for name in VIEW_LINKS:
src = layout.src / name
if src.exists():
(layout.view / name).symlink_to(src)
for name in VIEW_COPIES:
src = layout.src / name
if not src.is_file():
msg = f"required file missing: {src}"
raise FileNotFoundError(msg)
_ = shutil.copy2(src, layout.view / name)
def find_project_venv_scripts(layout: WineLayout) -> Path | None:
"""Locate PDM-created Windows project venv Scripts directory.
Prefer in-project ``project-view/.venv`` (common with ``python.use_venv``);
fall back to PDM's AppData venvs under the Wine prefix.
"""
in_project = layout.view / ".venv" / "Scripts"
if in_project.is_dir() and (in_project / "python.exe").is_file():
return in_project
users = layout.prefix / "drive_c" / "users"
if not users.is_dir():
return None
matches = sorted(p for p in users.glob("**/pdm/pdm/venvs/project-view-*/Scripts") if p.is_dir())
return matches[-1] if matches else None
def wine_pdm(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
if not layout.pdm_exe.is_file():
msg = "bootstrap pdm missing; run setup first"
raise RuntimeError(msg)
if not layout.view.is_dir():
refresh_project_view(layout)
return run(["wine", str(layout.pdm_exe), *args], env=env, cwd=layout.view)
def wine_basedpyright(layout: WineLayout, env: dict[str, str], paths: list[str]) -> int:
scripts = find_project_venv_scripts(layout)
if scripts is None:
msg = "project venv Scripts not found; run setup first"
raise RuntimeError(msg)
# Prefer Z: for in-project .venv; C: only when under prefix drive_c.
try:
scripts_win = to_wine_c(layout.prefix, scripts)
except ValueError:
scripts_win = to_wine_z(scripts)
out = layout.base / "basedpyright-out.txt"
out_z = to_wine_z(out)
# Node under Wine often hits EBADF on stderr; redirect via cmd.
args = " ".join(paths) if paths else "src"
cmdline = f"{scripts_win}\\basedpyright.exe {args} > {out_z} 2>&1"
code = run(["wine", "cmd", "/c", cmdline], env=env, cwd=layout.view)
if out.is_file():
_ = sys.stdout.write(out.read_text(encoding="utf-8", errors="replace"))
return code
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""CLI for an isolated Wine + Windows uv/Python + PDM environment.
Never installs into or rewrites the host project virtualenv.
Usage:
python scripts/wine_pdm.py setup
python scripts/wine_pdm.py pdm <args...>
python scripts/wine_pdm.py run <args...>
python scripts/wine_pdm.py check [paths...] # ruff + basedpyright (default: src)
python scripts/wine_pdm.py test [pytest-args...] # full suite (default: tests -q)
python scripts/wine_pdm.py ci # check + test
python scripts/wine_pdm.py pytest [args...] # alias of test
python scripts/wine_pdm.py basedpyright [paths...]
python scripts/wine_pdm.py uvx <args...>
python scripts/wine_pdm.py python <args...>
python scripts/wine_pdm.py shell
Env:
PLYNGENT_SRC, PLYNGENT_WINE_BASE, PLYNGENT_WINEPREFIX, PLYNGENT_WINEDEBUG
WIN_PYTHON (short uv request, default 3.14), WIN_UV_VERSION
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
# Static: sibling wine_env as a module (scripts/ on analysis path).
# Runtime uses importlib (else branch) because scripts/ is not a package.
from wine_env import (
WineLayout,
ensure_pdm_bootstrap,
ensure_prefix,
ensure_windows_python,
ensure_windows_uv,
find_project_venv_scripts,
refresh_project_view,
require_cmds,
run,
wine_basedpyright,
wine_pdm,
)
else:
# Runtime: load sibling by file path (scripts/ is not an installable package).
import importlib.util
def _load_wine_env():
path = Path(__file__).resolve().parent / "wine_env.py"
spec = importlib.util.spec_from_file_location("plyngent_scripts_wine_env", path)
if spec is None or spec.loader is None:
msg = f"cannot load {path}"
raise RuntimeError(msg)
module = importlib.util.module_from_spec(spec)
# dataclasses needs the module registered before exec_module.
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
_we = _load_wine_env()
WineLayout = _we.WineLayout
ensure_pdm_bootstrap = _we.ensure_pdm_bootstrap
ensure_prefix = _we.ensure_prefix
ensure_windows_python = _we.ensure_windows_python
ensure_windows_uv = _we.ensure_windows_uv
find_project_venv_scripts = _we.find_project_venv_scripts
refresh_project_view = _we.refresh_project_view
require_cmds = _we.require_cmds
run = _we.run
wine_basedpyright = _we.wine_basedpyright
wine_pdm = _we.wine_pdm
def cmd_setup(layout: WineLayout, env: dict[str, str], _args: list[str]) -> int:
require_cmds("wine", "wineboot")
ensure_prefix(layout, env)
# Windows uv first, then short-version CPython install inside Wine.
ensure_windows_uv(layout)
ensure_windows_python(layout, env)
ensure_pdm_bootstrap(layout, env)
refresh_project_view(layout)
print("Selecting Windows interpreter for PDM project-view ...", file=sys.stderr)
_ = wine_pdm(layout, env, ["use", "-f", layout.pdm_python_z])
print("pdm sync --dev (installs pywinpty on win32) ...", file=sys.stderr)
code = wine_pdm(layout, env, ["sync", "--dev"])
if code != 0:
return code
print()
print("Ready (host project venv untouched).")
print(f" PLYNGENT_SRC={layout.src}")
print(f" PLYNGENT_WINE_BASE={layout.base}")
print(f" WINEPREFIX={layout.prefix}")
print()
print("Examples:")
print(" python scripts/wine_pdm.py ci")
print(" python scripts/wine_pdm.py check")
print(" python scripts/wine_pdm.py test")
print(' python scripts/wine_pdm.py run python -c "import sys, winpty; print(sys.platform, winpty)"')
return 0
def cmd_pdm(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
return wine_pdm(layout, env, args)
def cmd_run(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
return wine_pdm(layout, env, ["run", *args])
def cmd_ruff(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
if not args:
args = ["src", "tests", "scripts"]
return wine_pdm(layout, env, ["run", "ruff", "check", *args])
def cmd_basedpyright(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
if not args:
args = ["src"]
return wine_basedpyright(layout, env, args)
def cmd_check(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
"""Ruff + basedpyright (full static check)."""
# Optional paths apply to both tools when provided.
ruff_args = list(args) if args else ["src", "tests", "scripts"]
pyright_args = list(args) if args else ["src"]
code = cmd_ruff(layout, env, ruff_args)
if code != 0:
return code
return cmd_basedpyright(layout, env, pyright_args)
def cmd_pytest(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
if not args:
args = ["tests", "-q"]
return wine_pdm(layout, env, ["run", "pytest", *args])
def cmd_ci(layout: WineLayout, env: dict[str, str], _args: list[str]) -> int:
"""Full Windows check: ruff + basedpyright + pytest."""
code = cmd_check(layout, env, [])
if code != 0:
return code
return cmd_pytest(layout, env, [])
def cmd_uvx(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
if not layout.uvx_exe.is_file():
print("error: Windows uvx missing; run setup first", file=sys.stderr)
return 1
return run(["wine", str(layout.uvx_exe), *args], env=env)
def cmd_python(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
scripts = find_project_venv_scripts(layout)
if scripts is None:
print("error: project venv missing; run setup first", file=sys.stderr)
return 1
return run(["wine", str(scripts / "python.exe"), *args], env=env)
def cmd_shell(layout: WineLayout, _env: dict[str, str], _args: list[str]) -> int:
print(f"PLYNGENT_SRC={layout.src}")
print(f"PLYNGENT_WINE_BASE={layout.base}")
print(f"WINEPREFIX={layout.prefix}")
print(f"WINE_VIEW={layout.view}")
print(f"WIN_PYTHON={layout.win_python_host}")
print(f"WIN_UV={layout.uv_exe}")
print(f"WIN_PDM={layout.pdm_exe}")
print()
print("# Typical flow:")
print("# python scripts/wine_pdm.py setup")
print("# python scripts/wine_pdm.py ci")
print('# python scripts/wine_pdm.py run python -c "import winpty; print(winpty)"')
return 0
_COMMANDS: dict[str, Callable[[WineLayout, dict[str, str], list[str]], int]] = {
"setup": cmd_setup,
"pdm": cmd_pdm,
"run": cmd_run,
"check": cmd_check,
"ruff": cmd_ruff,
"test": cmd_pytest,
"pytest": cmd_pytest,
"ci": cmd_ci,
"basedpyright": cmd_basedpyright,
"pyright": cmd_basedpyright,
"uvx": cmd_uvx,
"python": cmd_python,
"shell": cmd_shell,
"env": cmd_shell,
}
def main(argv: list[str] | None = None) -> int:
# Manual dispatch so flags like ``uvx --from`` are not eaten by argparse.
raw = list(sys.argv[1:] if argv is None else argv)
if not raw or raw[0] in {"-h", "--help"}:
print(__doc__ or "")
return 0
cmd = raw[0]
rest = raw[1:]
handler = _COMMANDS.get(cmd)
if handler is None:
print(f"error: unknown command {cmd!r}", file=sys.stderr)
return 2
layout = WineLayout.from_env()
env = layout.export_env()
try:
return handler(layout, env, rest)
except RuntimeError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Thin wrapper — all path logic lives in wine_pdm.py / wine_env.py.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec python3 "$root/scripts/wine_pdm.py" "$@"
+2
View File
@@ -14,6 +14,8 @@ from .events import ToolResultEvent as ToolResultEvent
from .events import UsageEvent as UsageEvent
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
from .loop import run_chat_loop as run_chat_loop
from .responses_client import ResponsesChatClient as ResponsesChatClient
from .responses_client import wrap_openai_for_agent as wrap_openai_for_agent
from .tools import DangerClassifier as DangerClassifier
from .tools import ToolConfirmHook as ToolConfirmHook
from .tools import ToolDefinition as ToolDefinition
+312
View File
@@ -0,0 +1,312 @@
"""Convert agent chat history/tools to OpenAI Responses API shapes and back.
Agent memory and events stay chat-completions-shaped; only the transport uses
Responses. DeepSeek / openai-compatible paths never enter this module.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from msgspec import UNSET
from plyngent.lmproto.openai.model import (
Response,
ResponseEasyInputMessage,
ResponseFunctionTool,
ResponseFunctionToolCallOutput,
response_function_calls,
response_output_text,
)
from plyngent.lmproto.openai_compatible.model import (
AnyAssistantToolCall,
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionChoice,
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
ChunkChoice,
DeltaMessage,
StreamFunctionDelta,
StreamToolCallDelta,
SystemChatMessage,
ToolChatMessage,
ToolFunctionItem,
UserChatMessage,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from plyngent.typedef import Unset
def tool_items_to_response_tools(
tools: Sequence[AnyToolItem] | None,
) -> list[ResponseFunctionTool]:
"""Map chat ``ToolFunctionItem`` list to flat Responses function tools."""
if not tools:
return []
result: list[ResponseFunctionTool] = []
for item in tools:
if not isinstance(item, ToolFunctionItem):
continue
fn = item.function
result.append(
ResponseFunctionTool(
name=fn.name,
description=fn.description if fn.description is not UNSET else UNSET,
parameters=fn.parameters if fn.parameters is not UNSET else UNSET,
strict=fn.strict if fn.strict is not UNSET else UNSET,
)
)
return result
def _assistant_to_input_items(
message: AssistantChatMessage,
) -> list[dict[str, Any] | ResponseEasyInputMessage]:
items: list[dict[str, Any] | ResponseEasyInputMessage] = []
if message.tool_calls is not UNSET and message.tool_calls:
items.extend(
{
"type": "function_call",
"call_id": call.id,
"name": call.function.name,
"arguments": call.function.arguments,
}
for call in message.tool_calls
if isinstance(call, AssistantFunctionToolCall)
)
if isinstance(message.content, str) and message.content:
items.append(ResponseEasyInputMessage(role="assistant", content=message.content))
return items
def chat_messages_to_responses_input(
messages: Sequence[AnyChatMessage],
) -> tuple[str | None, list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]]:
"""Split system prompts into ``instructions``; rest become Responses ``input`` items."""
instructions_parts: list[str] = []
items: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput] = []
for message in messages:
if isinstance(message, SystemChatMessage):
if message.content.strip():
instructions_parts.append(message.content)
elif isinstance(message, UserChatMessage):
items.append(ResponseEasyInputMessage(role="user", content=message.content))
elif isinstance(message, AssistantChatMessage):
items.extend(_assistant_to_input_items(message))
elif isinstance(message, ToolChatMessage):
items.append(
ResponseFunctionToolCallOutput(
call_id=message.tool_call_id,
output=message.content,
)
)
else:
content = getattr(message, "content", None)
if isinstance(content, str) and content:
items.append(ResponseEasyInputMessage(role="user", content=content))
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
return instructions, items
def response_to_assistant_message(response: Response) -> AssistantChatMessage:
"""Map a completed Responses object to agent ``AssistantChatMessage``."""
text = response_output_text(response)
calls = response_function_calls(response)
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
if calls:
tool_calls = [
AssistantFunctionToolCall(
id=call.call_id,
function=AssistantFunctionTool(name=call.name, arguments=call.arguments),
)
for call in calls
]
reasoning = _reasoning_summary_text(response)
return AssistantChatMessage(
content=text or None,
tool_calls=tool_calls,
reasoning_content=reasoning or UNSET,
)
def _reasoning_summary_text(response: Response) -> str:
parts: list[str] = []
for raw in response.output:
if raw.get("type") != "reasoning":
continue
summary = raw.get("summary")
if not isinstance(summary, list):
continue
summary_items = cast("list[object]", summary)
for block_obj in summary_items:
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"}:
text = block_map.get("text")
if isinstance(text, str) and text:
parts.append(text)
return "".join(parts)
def response_to_chat_completion(response: Response) -> ChatCompletionResponse:
"""Wrap Responses result as a synthetic chat completion for the agent loop."""
assistant = response_to_assistant_message(response)
finish: str | None = "tool_calls" if assistant.tool_calls is not UNSET else "stop"
usage = response.usage if response.usage is not UNSET else UNSET
created = int(response.created_at)
return ChatCompletionResponse(
id=response.id,
object="chat.completion",
created=created,
model=response.model,
choices=[
ChatCompletionChoice(
index=0,
message=assistant,
finish_reason=cast("Any", finish),
)
],
usage=cast("Any", usage) if usage is not UNSET else UNSET,
)
def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="resp-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(content=content),
)
],
)
def reasoning_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="resp-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(reasoning_content=content),
)
],
)
def tool_call_chunks_from_response(
response: Response,
*,
model: str,
created: int = 0,
) -> list[ChatCompletionChunk]:
"""Emit complete tool-call stream deltas (one chunk per call) for loop merge."""
calls = response_function_calls(response)
chunks: list[ChatCompletionChunk] = []
for index, call in enumerate(calls):
chunks.append(
ChatCompletionChunk(
id=response.id,
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(
tool_calls=[
StreamToolCallDelta(
index=index,
id=call.call_id,
type="function",
function=StreamFunctionDelta(
name=call.name,
arguments=call.arguments,
),
)
]
),
)
],
)
)
return chunks
def usage_chunk_from_response(response: Response, *, model: str) -> ChatCompletionChunk | None:
if response.usage is UNSET or response.usage is None:
return None
created = int(response.created_at)
return ChatCompletionChunk(
id=response.id,
object="chat.completion.chunk",
created=created,
model=model,
choices=[],
usage=response.usage,
)
def _merge_response_tools(
param: ChatCompletionsParam,
provider_tools: Sequence[dict[str, Any]] | None,
) -> list[ResponseFunctionTool | dict[str, Any]]:
tools: list[ResponseFunctionTool | dict[str, Any]] = list(
tool_items_to_response_tools(param.tools if param.tools is not UNSET else None)
)
if provider_tools:
tools.extend(dict(item) for item in provider_tools if item.get("type"))
return tools
def chat_param_to_responses_kwargs(
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build keyword args for :class:`ResponsesCreateParam` from a chat param.
*provider_tools* are hosted tools (web_search, file_search, …) as opaque
dicts; they are merged after local function tools and never executed by
:class:`~plyngent.agent.tools.ToolRegistry`.
"""
instructions, input_items = chat_messages_to_responses_input(param.messages)
tools = _merge_response_tools(param, provider_tools)
kwargs: dict[str, Any] = {
"model": param.model,
"input": input_items or "",
"store": False,
}
if instructions:
kwargs["instructions"] = instructions
if tools:
kwargs["tools"] = tools
if param.temperature is not UNSET:
kwargs["temperature"] = param.temperature
if param.top_p is not UNSET:
kwargs["top_p"] = param.top_p
if param.max_completion_tokens is not UNSET:
kwargs["max_output_tokens"] = param.max_completion_tokens
elif param.max_tokens is not UNSET:
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.tool_choice is not UNSET and isinstance(param.tool_choice, str):
kwargs["tool_choice"] = param.tool_choice
return kwargs
+126
View File
@@ -0,0 +1,126 @@
"""ChatClient adapter: agent chat loop over OpenAI Responses API."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, overload
from msgspec import UNSET
from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
reasoning_delta_chunk,
response_to_chat_completion,
text_delta_chunk,
tool_call_chunks_from_response,
usage_chunk_from_response,
)
from plyngent.lmproto.openai.model import ResponsesCreateParam
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai.model import Response
from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
)
class ResponsesChatClient:
"""Present OpenAI Responses as :class:`~plyngent.agent.client.ChatClient`.
History and tool results remain chat-completions-shaped; only the HTTP call
uses ``POST /responses``. Optional *provider_tools* are hosted tools merged
into the request (not local registry handlers).
"""
_client: OpenAIClient
_provider_tools: list[dict[str, Any]]
def __init__(
self,
client: OpenAIClient,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> None:
self._client = client
self._provider_tools = [dict(t) for t in provider_tools] if provider_tools else []
async def models(self) -> list[str]:
return await self._client.models()
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
kwargs = chat_param_to_responses_kwargs(
param,
provider_tools=self._provider_tools or None,
)
create = ResponsesCreateParam(**kwargs)
if stream:
return self._stream_as_chat_chunks(create, model=param.model)
response = await self._client.responses(create, stream=False)
return response_to_chat_completion(response)
async def _stream_as_chat_chunks(
self,
create: ResponsesCreateParam,
*,
model: str,
) -> AsyncIterator[ChatCompletionChunk]:
stream = await self._client.responses(create, stream=True)
final: Response | None = None
async for event in stream:
etype = event.type
if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta:
yield text_delta_chunk(model=model, content=event.delta)
continue
if (
etype
in {
"response.reasoning_summary_text.delta",
"response.reasoning_text.delta",
}
and isinstance(event.delta, str)
and event.delta
):
yield reasoning_delta_chunk(model=model, content=event.delta)
continue
if etype == "response.completed" and event.response is not UNSET:
# Decode full response for tools + usage (field is dict | Unset).
import msgspec
from plyngent.lmproto.openai.model import Response as ResponseModel
try:
final = msgspec.convert(event.response, ResponseModel)
except TypeError, ValueError, msgspec.ValidationError:
final = None
if final is not None:
for chunk in tool_call_chunks_from_response(final, model=model):
yield chunk
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
def wrap_openai_for_agent(
client: OpenAIClient,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> ResponsesChatClient:
"""Wrap a platform OpenAI client so the agent uses Responses by default."""
return ResponsesChatClient(client, provider_tools=provider_tools)
+9 -1
View File
@@ -86,14 +86,22 @@ def _as_nonneg_int(value: object) -> int:
def token_usage_from_api(usage: object) -> TokenUsage | None:
"""Parse OpenAI-style usage dict; return None if missing/empty."""
"""Parse OpenAI-style usage dict; return None if missing/empty.
Accepts chat completions fields (``prompt_tokens`` / ``completion_tokens``)
and Responses fields (``input_tokens`` / ``output_tokens``).
"""
if usage is None or usage is UNSET:
return None
if not isinstance(usage, dict):
return None
raw = cast("dict[str, object]", usage)
prompt = _as_nonneg_int(raw.get("prompt_tokens"))
if prompt == 0:
prompt = _as_nonneg_int(raw.get("input_tokens"))
completion = _as_nonneg_int(raw.get("completion_tokens"))
if completion == 0:
completion = _as_nonneg_int(raw.get("output_tokens"))
total = _as_nonneg_int(raw.get("total_tokens"))
if total == 0 and (prompt or completion):
total = prompt + completion
+33 -6
View File
@@ -46,7 +46,7 @@ def _load_config(config_path: Path | None) -> ConfigStore:
def _warn_bad_providers(bad: Mapping[str, object]) -> None:
"""Surface ignored provider entries (parse errors, empty models, …)."""
"""Surface ignored provider entries (parse errors, unknown fields, …)."""
if not bad:
return
names = ", ".join(sorted(bad.keys()))
@@ -68,6 +68,18 @@ def _warn_bad_providers(bad: Mapping[str, object]) -> None:
click.secho(f" - {name}: {reason}", fg="yellow", err=True)
def _warn_recoverable_providers(recoverable: Mapping[str, object]) -> None:
"""Surface providers with empty models (recoverable via GET /models)."""
if not recoverable:
return
names = ", ".join(sorted(recoverable.keys()))
click.secho(
f"warning: providers with empty models ({len(recoverable)}): {names} (will try GET /models or --model on use)",
fg="yellow",
err=True,
)
def _database_config(store: ConfigStore, *, quiet: bool = False) -> DatabaseConfig:
raw = dict(store.database)
# Prefer a durable file DB so sessions survive CLI restarts.
@@ -165,7 +177,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 — chat orchestration
async def _run_chat( # noqa: C901, PLR0912 — chat orchestration
*,
config_path: Path | None,
provider_name: str | None,
@@ -187,14 +199,19 @@ async def _run_chat( # noqa: C901 — chat orchestration
configure_prompting(backend=NonInteractiveBackend())
store = _load_config(config_path)
if store.bad_providers and not quiet:
_warn_bad_providers(store.bad_providers)
if not quiet:
if store.bad_providers:
_warn_bad_providers(store.bad_providers)
if store.recoverable_providers:
_warn_recoverable_providers(store.recoverable_providers)
_setup_workspace_and_hooks(store, workspace, interactive=interactive)
confirm_destructive: bool | None = False if yes else None
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
try:
from plyngent.cli.provider_recovery import ensure_provider_ready
# Prefer session-remembered LLM when resuming (unless flags override).
preferred_provider = provider_name
preferred_model = model
@@ -211,10 +228,17 @@ async def _run_chat( # noqa: C901 — chat orchestration
try:
pname, provider = select_provider(
store.providers,
store.selectable_providers(),
preferred=preferred_provider,
interactive=interactive,
)
provider = await ensure_provider_ready(
store,
pname,
provider,
preferred_model=preferred_model,
interactive=interactive,
)
model_id = select_model(provider, preferred=preferred_model, interactive=interactive)
_ = create_client(provider)
except ProviderNotSupportedError as exc:
@@ -414,12 +438,15 @@ def chat_cmd(
def providers_cmd(config_path: Path | None) -> None:
"""List configured providers."""
store = _load_config(config_path)
if not store.providers:
if not store.providers and not store.recoverable_providers:
click.echo("(no providers)")
for name, provider in sorted(store.providers.items()):
tag = type(provider).__struct_config__.tag
models = ", ".join(sorted(provider.models.keys())) or "(none listed)"
click.echo(f"{name}\tpreset={tag}\tmodels={models}")
for name, provider in sorted(store.recoverable_providers.items()):
tag = type(provider).__struct_config__.tag
click.echo(f"{name}\tpreset={tag}\tmodels=(empty; recoverable)")
if store.bad_providers:
_warn_bad_providers(store.bad_providers)
+98 -9
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import contextlib
import os
import sys
from contextvars import ContextVar
from typing import TYPE_CHECKING
@@ -25,8 +28,9 @@ if TYPE_CHECKING:
_TOOL_RESULT_PREVIEW = 120
_TOOL_ARGS_PREVIEW = 80
# Process/session display flag for tool result dumps (set from ReplState / slash).
# Process/session display flags (set from ReplState / slash).
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
_markdown_enabled: ContextVar[bool] = ContextVar("markdown_enabled", default=True)
def set_verbose_tool_results(enabled: bool) -> None: # noqa: FBT001
@@ -38,6 +42,25 @@ def get_verbose_tool_results() -> bool:
return _verbose_tool_results.get()
def set_markdown_enabled(enabled: bool) -> None: # noqa: FBT001
"""Enable or disable end-of-turn Rich markdown rendering."""
_ = _markdown_enabled.set(enabled)
def get_markdown_enabled() -> bool:
return _markdown_enabled.get()
def markdown_render_available() -> bool:
"""True when stdout is a TTY and plain mode is not forced via env."""
if os.environ.get("PLYNGENT_PLAIN", "").strip() in {"1", "true", "yes", "on"}:
return False
try:
return sys.stdout.isatty()
except AttributeError, OSError, ValueError:
return False
def _preview(text: str, limit: int) -> str:
if len(text) <= limit:
return text
@@ -47,23 +70,64 @@ def _preview(text: str, limit: int) -> str:
def _echo_stream(text: str) -> None:
"""Write without newline and flush so assistant text appears token-by-token."""
click.echo(text, nl=False)
try:
import sys
with contextlib.suppress(OSError):
_ = sys.stdout.flush()
except OSError:
pass
def _clear_streamed_lines(line_count: int) -> None:
"""Move cursor up and clear the streamed plain-text region (TTY only)."""
if line_count <= 0:
return
# Clear current line, then each previous line of the streamed block.
for _ in range(line_count):
_ = sys.stdout.write("\r\033[2K\033[1A")
_ = sys.stdout.write("\r\033[2K")
with contextlib.suppress(OSError):
_ = sys.stdout.flush()
def _line_count_for_clear(label: str, body: str) -> int:
"""Approximate terminal lines used by ``label + body`` for cursor erase."""
if not body and not label:
return 0
text = label + body
# +1 for trailing newline after the stream block in render_events.
return text.count("\n") + 1
def print_markdown(text: str, *, label: str = "assistant: ") -> None:
"""Render *text* as markdown via Rich, with a cyan label."""
from rich.console import Console
from rich.markdown import Markdown
from rich.text import Text
console = Console(file=sys.stdout, highlight=False)
if label:
console.print(Text(label, style="cyan"), end="")
console.print(Markdown(text))
async def render_events( # noqa: C901, PLR0912, PLR0915
events: AsyncIterator[AgentEvent],
*,
verbose: bool | None = None,
markdown: bool | None = None,
) -> None:
"""Print agent events to the terminal (text deltas stream as they arrive)."""
"""Print agent events to the terminal (text deltas stream as they arrive).
When markdown is enabled and stdout is a TTY, the plain streamed assistant
text is replaced at end-of-turn with a Rich markdown render.
"""
show_full = get_verbose_tool_results() if verbose is None else verbose
use_markdown = get_markdown_enabled() if markdown is None else markdown
pretty = bool(use_markdown and markdown_render_available())
printed_reasoning = False
printed_text = False
assistant_buf: list[str] = []
# Tools/errors after assistant text: skip replace (would erase tool lines).
interrupted_by_other = False
async for event in events:
if isinstance(event, ReasoningDeltaEvent):
if not printed_reasoning:
@@ -78,8 +142,11 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
click.echo()
click.secho("assistant: ", fg="cyan", nl=False)
printed_text = True
assistant_buf.append(event.content)
_echo_stream(event.content)
elif isinstance(event, ToolCallEvent):
if printed_text:
interrupted_by_other = True
call = event.tool_call
if isinstance(call, AssistantFunctionToolCall):
args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW)
@@ -87,15 +154,18 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
else:
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
elif isinstance(event, ToolResultEvent):
if printed_text:
interrupted_by_other = True
content = event.message.content
if show_full:
click.secho(f"[tool ok]\n{content}", fg="magenta")
else:
preview = _preview(content, _TOOL_RESULT_PREVIEW)
# Single-line summary; full result stays in the message history for the model.
one_line = preview.replace("\n", " ")
click.secho(f"[tool ok] {one_line}", fg="magenta")
elif isinstance(event, ErrorEvent):
if printed_text:
interrupted_by_other = True
suffix = ""
if event.source:
suffix += f" source={event.source}"
@@ -103,11 +173,15 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
suffix += " (fatal)"
click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red")
elif isinstance(event, CancelledEvent):
if printed_text:
interrupted_by_other = True
if event.reason:
click.secho(f"\n[cancelled] {event.reason}", fg="yellow")
else:
click.secho("\n[cancelled]", fg="yellow")
elif isinstance(event, MaxRoundsEvent):
if printed_text:
interrupted_by_other = True
if event.continued:
click.secho(
f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]",
@@ -116,11 +190,26 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
else:
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
elif isinstance(event, UsageEvent):
# Printed at end of turn as a summary; individual round usage is quiet.
_ = event
else:
# AssistantMessageEvent — text already shown via TextDeltaEvent.
_ = event
full_assistant = "".join(assistant_buf)
if pretty and printed_text and full_assistant.strip() and not interrupted_by_other:
# Replace plain stream with markdown (assistant block only).
lines = _line_count_for_clear("assistant: ", full_assistant)
# Also account for blank line before label when reasoning was shown.
if printed_reasoning:
lines += 1
_clear_streamed_lines(lines)
if printed_reasoning:
# Reasoning already printed above; leave it and only re-print assistant.
pass
print_markdown(full_assistant, label="assistant: ")
click.echo()
return
if printed_text or printed_reasoning:
click.echo()
click.echo()
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import inspect
from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from plyngent.config.models import Provider
# Cache remote catalog this long (seconds) unless /models --refresh.
DEFAULT_MODELS_CACHE_TTL = 300.0
@runtime_checkable
class SupportsModels(Protocol):
async def models(self) -> list[str]: ...
def config_model_ids(provider: Provider) -> list[str]:
"""Sorted model ids declared in provider config."""
return sorted(provider.models.keys())
def merge_model_choices(
config_ids: Iterable[str],
remote_ids: Iterable[str] | None = None,
) -> list[str]:
"""Union config and remote ids (sorted, unique)."""
merged: set[str] = {i for i in config_ids if i}
if remote_ids is not None:
merged.update(i for i in remote_ids if i)
return sorted(merged)
def client_supports_models(client: object) -> bool:
"""True when *client* exposes OpenAI-compatible ``models()``."""
return isinstance(client, SupportsModels) or callable(getattr(client, "models", None))
async def fetch_remote_model_ids(client: object) -> list[str]:
"""Call ``client.models()``; raise if missing or the call fails."""
method = getattr(client, "models", None)
if not callable(method):
msg = "client does not support listing models"
raise TypeError(msg)
result = method()
if inspect.isawaitable(result):
result = await result
if not isinstance(result, list):
msg = f"models() returned unexpected type {type(result)!r}"
raise TypeError(msg)
return [str(item) for item in cast("list[object]", result) if item]
def model_choices_for_provider(
provider: Provider,
*,
remote_ids: Sequence[str] | None = None,
) -> list[str]:
"""Config plus remote catalog for selection / Tab complete."""
return merge_model_choices(config_model_ids(provider), remote_ids)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import click
from plyngent.cli.models_source import fetch_remote_model_ids
from plyngent.runtime import ProviderNotSupportedError, create_client
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.config.models import Provider
from plyngent.config.store import ConfigStore
async def discover_model_ids(provider: Provider) -> list[str]:
"""``GET /models`` for *provider*; empty list if unsupported or empty catalog."""
try:
client = create_client(provider)
except ProviderNotSupportedError:
return []
try:
return await fetch_remote_model_ids(client)
except RuntimeError, TypeError, OSError, ValueError:
return []
async def try_promote_provider(
store: ConfigStore,
name: str,
*,
seed_model_ids: Sequence[str] | None = None,
) -> Provider | None:
"""Promote recoverable *name* into ready providers.
Prefers *seed_model_ids* when non-empty; otherwise remote ``models()``.
Returns the promoted provider, or ``None`` if recovery failed.
"""
if name in store.providers:
return store.providers[name]
if name not in store.recoverable_providers:
return None
provider = store.recoverable_providers[name]
ids: list[str] = []
if seed_model_ids:
ids = [mid.strip() for mid in seed_model_ids if mid and str(mid).strip()]
if not ids:
ids = await discover_model_ids(provider)
if not ids:
return None
return store.promote_provider(name, ids)
async def ensure_provider_ready(
store: ConfigStore,
name: str,
provider: Provider,
*,
preferred_model: str | None = None,
interactive: bool = True,
) -> Provider:
"""Return a ready provider; recover empty-models entries when possible.
Raises:
click.ClickException: When recovery is required but fails.
"""
if provider.models:
return provider
if name not in store.recoverable_providers and name not in store.providers:
msg = f"provider {name!r} has no models and is not recoverable"
raise click.ClickException(msg)
seeds: list[str] = []
if preferred_model and preferred_model.strip():
seeds = [preferred_model.strip()]
promoted = await try_promote_provider(store, name, seed_model_ids=seeds or None)
if promoted is not None:
click.secho(
f"recovered provider {name!r} with {len(promoted.models)} model(s) (was empty models in config)",
fg="yellow",
err=True,
)
return promoted
# Remote failed and no preferred seed: interactive free-form model id.
if interactive and not seeds:
from plyngent.prompting import ask
mid = ask(f"Model id for provider {name!r} (empty models; remote list failed)")
if mid.strip():
promoted = store.promote_provider(name, [mid.strip()])
click.secho(
f"recovered provider {name!r} with model {mid.strip()!r}",
fg="yellow",
err=True,
)
return promoted
if seeds:
# Prefer explicit model even when remote list failed.
promoted = store.promote_provider(name, seeds)
click.secho(
f"recovered provider {name!r} with model {seeds[0]!r}",
fg="yellow",
err=True,
)
return promoted
msg = f"provider {name!r} has empty models and could not be recovered (pass --model or fix GET /models)"
raise click.ClickException(msg)
+19 -7
View File
@@ -4,10 +4,11 @@ from typing import TYPE_CHECKING
import click
from plyngent.cli.models_source import config_model_ids, model_choices_for_provider
from plyngent.prompting import ChoiceOption, choose
if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from plyngent.config.models import Provider
@@ -56,14 +57,20 @@ def select_model(
*,
preferred: str | None = None,
interactive: bool = True,
choices: Sequence[str] | None = None,
) -> str:
"""Pick a model id from provider.models or free-form prompt (readline + Tab)."""
model_names = sorted(provider.models.keys())
"""Pick a model id from config/remote choices or free-form prompt.
*choices* overrides the default list (config keys). Explicit *preferred*
is accepted even when not in the list (API validates at chat time).
"""
model_names = list(choices) if choices is not None else config_model_ids(provider)
if preferred is not None:
if model_names and preferred not in provider.models:
msg = f"unknown model {preferred!r}; available: {', '.join(model_names)}"
token = preferred.strip()
if not token:
msg = "model id must not be empty"
raise click.ClickException(msg)
return preferred
return token
if len(model_names) == 1:
model = model_names[0]
@@ -81,9 +88,14 @@ def select_model(
return choose(
"Select model",
model_names,
allow_custom=False,
allow_custom=True,
)
from plyngent.prompting import ask
return ask("Model id (not listed in config)")
def default_model_choices(provider: Provider, remote_ids: Sequence[str] | None = None) -> list[str]:
"""Config plus optional remote ids (for Tab / interactive pick)."""
return model_choices_for_provider(provider, remote_ids=remote_ids)
+114 -9
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
import shlex
from typing import TYPE_CHECKING, Any, cast, override
@@ -8,6 +9,7 @@ import click
from click.shell_completion import CompletionItem
from msgspec import UNSET
from plyngent.cli.models_source import DEFAULT_MODELS_CACHE_TTL, model_choices_for_provider
from plyngent.cli.retry import retry_pending_with_retries
from plyngent.cli.selection import select_model, select_provider
from plyngent.lmproto.openai_compatible.model import (
@@ -117,7 +119,7 @@ class ProviderNameParam(click.ParamType[str]):
state = _repl_state(ctx)
if state is None:
return []
return _filter_choices(incomplete, sorted(state.config.providers.keys()))
return _filter_choices(incomplete, sorted(state.config.selectable_providers().keys()))
PROVIDER_NAME = ProviderNameParam()
@@ -137,7 +139,7 @@ class ModelIdParam(click.ParamType[str]):
state = _repl_state(ctx)
if state is None:
return []
return _filter_choices(incomplete, sorted(state.provider.models.keys()))
return _filter_choices(incomplete, state.model_choice_ids())
MODEL_ID = ModelIdParam()
@@ -316,6 +318,12 @@ def config_cmd(state: ReplState) -> None:
click.secho(f"error: config reload failed: {exc}", fg="red")
click.echo(f"config file: {path}")
return
if state.config.recoverable_providers:
names = ", ".join(sorted(state.config.recoverable_providers.keys()))
click.secho(
f"recoverable providers (empty models): {names}",
fg="yellow",
)
if state.config.bad_providers:
names = ", ".join(sorted(state.config.bad_providers.keys()))
click.secho(f"warning: ignored bad providers: {names}", fg="yellow")
@@ -554,16 +562,37 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
click.echo(f"provider={state.provider_name}")
return
try:
pname, provider = select_provider(state.config.providers, preferred=name.strip())
from plyngent.cli.provider_recovery import ensure_provider_ready
pname, provider = select_provider(
state.config.selectable_providers(),
preferred=name.strip(),
)
provider = _await(
ensure_provider_ready(
state.config,
pname,
provider,
preferred_model=state.model,
interactive=True,
)
)
prev_model = state.model
state.provider_name = pname
state.provider = provider
if prev_model in provider.models:
state.rebuild_client()
choices = _await(state.merged_model_choices(refresh=False))
if prev_model and (prev_model in choices or prev_model in provider.models):
state.model = prev_model
else:
# Current model not on the new provider — pick one (prompt when interactive).
try:
state.model = select_model(provider, preferred=None, interactive=True)
state.model = select_model(
provider,
preferred=None,
interactive=True,
choices=choices,
)
except click.ClickException as exc:
click.echo(f"error: switched provider but model selection failed: {exc}")
return
@@ -572,23 +601,80 @@ def provider_cmd(state: ReplState, name: str | None) -> None:
f"model {prev_model!r} is not available on {pname}; using {state.model!r}",
fg="yellow",
)
state.rebuild_client()
state.rebuild_client()
_await(state.persist_llm_selection())
click.echo(f"switched provider to {pname} model={state.model}")
except (click.ClickException, ProviderNotSupportedError) as exc:
click.echo(f"error: {exc}")
@slash.command("models")
@click.option("--refresh", is_flag=True, help="Bypass cache and re-fetch GET /models.")
@click.pass_obj
def models_cmd(state: ReplState, *, refresh: bool) -> None:
"""List models (config plus remote GET /models)."""
remote: list[str] | None = None
remote_err: str | None = None
try:
remote = _await(state.ensure_remote_models(refresh=refresh))
except (RuntimeError, TypeError, OSError, ValueError) as exc:
remote_err = str(exc)
remote = state.cached_remote_models()
# Promote empty-models recoverable provider after a successful remote list.
if remote and state.provider_name in state.config.recoverable_providers:
try:
state.provider = state.config.promote_provider(state.provider_name, remote)
state.rebuild_client()
click.secho(
f"recovered provider {state.provider_name!r} from remote catalog",
fg="yellow",
err=True,
)
except (KeyError, ValueError) as exc:
click.secho(f"could not recover provider: {exc}", fg="yellow", err=True)
config_ids = set(state.config_model_ids())
choices = model_choices_for_provider(state.provider, remote_ids=remote)
if not choices:
click.echo("(no models in config or remote catalog)")
else:
remote_set = set(remote or ())
for mid in choices:
tags: list[str] = []
if mid in config_ids:
tags.append("config")
if mid in remote_set:
tags.append("remote")
suffix = f" ({', '.join(tags)})" if tags else ""
mark = " *" if mid == state.model else ""
click.echo(f"{mid}{mark}{suffix}")
if remote_err is not None:
click.secho(f"remote list unavailable: {remote_err}", fg="yellow", err=True)
elif remote is not None:
click.echo(
f"({len(remote)} remote, {len(config_ids)} config; cache TTL {int(DEFAULT_MODELS_CACHE_TTL)}s)",
err=True,
)
@slash.command("model")
@click.argument("model_id", required=False, type=MODEL_ID)
@click.pass_obj
def model_cmd(state: ReplState, model_id: str | None) -> None:
"""Show or switch model."""
"""Show or switch model (Tab: config plus cached remote)."""
if not model_id:
click.echo(f"model={state.model}")
return
try:
state.model = select_model(state.provider, preferred=model_id.strip())
choices = _await(state.merged_model_choices(refresh=False))
state.model = select_model(
state.provider,
preferred=model_id.strip(),
choices=choices,
)
state.rebuild_client()
_await(state.persist_llm_selection())
click.echo(f"switched model to {state.model}")
@@ -648,6 +734,24 @@ def verbose_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
click.echo(f"verbose={'on' if enabled else 'off'}")
@slash.command("markdown")
@click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]")
@click.pass_obj
def markdown_cmd(state: ReplState, enabled: bool | None) -> None: # noqa: FBT001
"""Show or set end-of-turn Rich markdown for assistant text.
``on`` (default on TTY): stream plain tokens, then re-render as markdown.
``off``: leave streamed plain text as-is.
Non-TTY / ``PLYNGENT_PLAIN=1`` never pretty-prints.
"""
if enabled is None:
click.echo(f"markdown={'on' if state.markdown_enabled else 'off'}")
return
state.markdown_enabled = enabled
state.sync_display_flags()
click.echo(f"markdown={'on' if enabled else 'off'}")
@slash.command("rounds")
@click.argument("n", required=False, type=int)
@click.pass_obj
@@ -749,7 +853,8 @@ async def handle_slash(state: ReplState, line: str) -> bool:
if not body:
return True
try:
args = shlex.split(body)
# Windows paths use backslashes; POSIX shlex would treat them as escapes.
args = shlex.split(body, posix=os.name != "nt")
except ValueError as exc:
click.echo(f"error: {exc}")
return True
+105 -15
View File
@@ -1,11 +1,20 @@
from __future__ import annotations
import contextlib
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, cast
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
from plyngent.cli.models_source import (
DEFAULT_MODELS_CACHE_TTL,
client_supports_models,
config_model_ids,
fetch_remote_model_ids,
model_choices_for_provider,
)
from plyngent.memory.database.store import normalize_workspace
from plyngent.runtime import create_client
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
@@ -31,6 +40,8 @@ class ReplState:
max_rounds: int = DEFAULT_MAX_ROUNDS
stream_enabled: bool = True
verbose: bool = False
# End-of-turn Rich markdown for assistant text (TTY only).
markdown_enabled: bool = True
# One-shot / scripts: never prompt to raise tool-loop limits.
interactive_limits: bool = True
# When False, skip destructive-tool confirms (e.g. --yes).
@@ -40,6 +51,11 @@ class ReplState:
client: ChatClient = field(init=False)
agent: ChatAgent = field(init=False)
session_id: int | None = None
# Remote GET /models cache (per provider base).
_remote_models: list[str] | None = field(default=None, init=False, repr=False)
_remote_models_fetched_at: float | None = field(default=None, init=False, repr=False)
_remote_models_key: tuple[str, str] | None = field(default=None, init=False, repr=False)
_remote_models_error: str | None = field(default=None, init=False, repr=False)
def __post_init__(self) -> None:
# DeepSeek client uses a compatible but distinct param type; treat as ChatClient.
@@ -49,9 +65,10 @@ class ReplState:
self.sync_display_flags()
def sync_display_flags(self) -> None:
from plyngent.cli.display import set_verbose_tool_results
from plyngent.cli.display import set_markdown_enabled, set_verbose_tool_results
set_verbose_tool_results(self.verbose)
set_markdown_enabled(self.markdown_enabled)
def _workspace_key(self) -> str:
key = normalize_workspace(self.workspace)
@@ -110,6 +127,73 @@ class ReplState:
self.agent = self._make_agent()
self.agent.messages = messages
self.sync_display_flags()
# Drop remote catalog when provider identity/url changed (not on model-only switch).
if self._remote_models_key is not None and self._remote_models_key != self._models_cache_key():
self.invalidate_remote_models()
def _models_cache_key(self) -> tuple[str, str]:
url = getattr(self.provider, "url", "") or ""
return (self.provider_name, str(url))
def invalidate_remote_models(self) -> None:
"""Drop cached remote model catalog (provider/client change)."""
self._remote_models = None
self._remote_models_fetched_at = None
self._remote_models_key = None
self._remote_models_error = None
def cached_remote_models(self) -> list[str] | None:
"""Return cached remote ids if still valid for the current provider."""
if self._remote_models is None or self._remote_models_fetched_at is None:
return None
if self._remote_models_key != self._models_cache_key():
return None
age = time.monotonic() - self._remote_models_fetched_at
if age > DEFAULT_MODELS_CACHE_TTL:
return None
return list(self._remote_models)
def model_choice_ids(self, *, include_remote_cache: bool = True) -> list[str]:
"""Config plus optional cached remote ids (no network)."""
remote = self.cached_remote_models() if include_remote_cache else None
return model_choices_for_provider(self.provider, remote_ids=remote)
async def ensure_remote_models(self, *, refresh: bool = False) -> list[str]:
"""Fetch ``GET /models`` (cached) and return remote ids.
Raises RuntimeError/TypeError when the client cannot list models or
the request fails. On failure the previous cache is left unchanged.
"""
if not refresh:
cached = self.cached_remote_models()
if cached is not None:
return cached
if not client_supports_models(self.client):
msg = "client does not support listing models"
self._remote_models_error = msg
raise TypeError(msg)
try:
ids = await fetch_remote_model_ids(self.client)
except (RuntimeError, TypeError, OSError, ValueError) as exc:
self._remote_models_error = str(exc)
raise
self._remote_models = list(ids)
self._remote_models_fetched_at = time.monotonic()
self._remote_models_key = self._models_cache_key()
self._remote_models_error = None
return list(ids)
async def merged_model_choices(self, *, refresh: bool = False) -> list[str]:
"""Config plus remote catalog; remote fetch best-effort when refresh/missing."""
remote: list[str] | None
try:
remote = await self.ensure_remote_models(refresh=refresh)
except RuntimeError, TypeError, OSError, ValueError:
remote = self.cached_remote_models()
return model_choices_for_provider(self.provider, remote_ids=remote)
def config_model_ids(self) -> list[str]:
return config_model_ids(self.provider)
def reload_config_from_disk(self) -> None:
"""Re-read TOML config and re-bind provider/model when still valid."""
@@ -122,27 +206,32 @@ class ReplState:
self.config.reload()
set_path_denylist(self.config.agent_config.path_denylist or None)
preferred_provider = self.provider_name if self.provider_name in self.config.providers else None
selectable = self.config.selectable_providers()
preferred_provider = self.provider_name if self.provider_name in selectable else None
preferred_model = self.model
try:
pname, provider = select_provider(
self.config.providers,
selectable,
preferred=preferred_provider,
interactive=False,
)
except (click.ClickException, ProviderNotSupportedError) as exc:
msg = f"config reloaded but provider selection failed: {exc}"
raise ValueError(msg) from exc
# Empty-models providers stay recoverable until next use /models promote.
if not provider.models and preferred_model:
with contextlib.suppress(KeyError, ValueError):
provider = self.config.promote_provider(pname, [preferred_model])
try:
model_id = select_model(provider, preferred=preferred_model, interactive=False)
except click.ClickException:
# Previous model not on this provider; pick first listed or keep free-form.
model_id = next(iter(sorted(provider.models.keys()))) if provider.models else preferred_model
self.provider_name = pname
self.provider = provider
self.model = model_id
self.rebuild_client()
self.invalidate_remote_models()
def _set_workspace(self, path: Path) -> None:
"""Update REPL + tool workspace root."""
@@ -175,16 +264,22 @@ class ReplState:
from plyngent.cli.selection import select_provider
from plyngent.runtime import ProviderNotSupportedError
if pname not in self.config.providers:
if pname not in self.config.selectable_providers():
return False
try:
name, provider = select_provider(
self.config.providers,
self.config.selectable_providers(),
preferred=pname,
interactive=False,
)
except click.ClickException, ProviderNotSupportedError:
return False
# Session resume: seed empty recoverable with remembered model if any.
if not provider.models and self.model:
try:
provider = self.config.promote_provider(name, [self.model])
except KeyError, ValueError:
return False
if name != self.provider_name or provider is not self.provider:
self.provider_name = name
self.provider = provider
@@ -192,16 +287,11 @@ class ReplState:
return False
def _try_set_model(self, model_id: str) -> bool:
import click
from plyngent.cli.selection import select_model
try:
resolved = select_model(self.provider, preferred=model_id, interactive=False)
except click.ClickException:
token = model_id.strip()
if not token:
return False
if resolved != self.model:
self.model = resolved
if token != self.model:
self.model = token
return True
return False
+30 -1
View File
@@ -1,3 +1,5 @@
from typing import Any
from msgspec import Struct, field
@@ -51,8 +53,35 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True):
models: dict[str, ModelConfig] = field(default_factory=dict)
def _default_openai_models() -> dict[str, ModelConfig]:
"""Current OpenAI text catalog when TOML omits ``models``."""
return {
"gpt-5.4": ModelConfig(text=True),
"gpt-5.4-mini": ModelConfig(text=True),
"gpt-5.4-nano": ModelConfig(text=True),
}
def _default_openai_provider_tools() -> list[dict[str, Any]]:
"""Hosted tools when TOML omits ``provider_tools``."""
return [{"type": "web_search"}]
class OpenAIProvider(ProviderConfig, tag="openai"):
"""OpenAI API provider."""
"""OpenAI platform provider (agent uses Responses API).
``provider_tools`` are hosted/provider-side tools (e.g. web_search) passed
through to ``POST /responses`` as opaque dicts. They are **not** local
``ToolRegistry`` handlers and are ignored by non-OpenAI clients.
When ``models`` is omitted in TOML, seeds a small default catalog (same idea
as DeepSeek). Explicit ``models = {}`` stays empty (recoverable / free-form).
When ``provider_tools`` is omitted, defaults to web_search; use
``provider_tools = []`` to disable hosted tools.
"""
models: dict[str, ModelConfig] = field(default_factory=_default_openai_models)
provider_tools: list[dict[str, Any]] = field(default_factory=_default_openai_provider_tools)
class OpenAICompatibleProvider(ProviderConfig, tag="openai-compatible"):
+72 -17
View File
@@ -6,10 +6,10 @@ from typing import TYPE_CHECKING, cast
import msgspec
import tomlkit
from .models import AgentConfig, DatabaseConfig, Provider
from .models import AgentConfig, DatabaseConfig, ModelConfig, Provider
if TYPE_CHECKING:
from collections.abc import Mapping, MutableMapping
from collections.abc import Mapping, MutableMapping, Sequence
from pathlib import Path
@@ -35,28 +35,39 @@ def _parse_agent(raw: dict[str, object]) -> AgentConfig:
def _parse_providers(
document: tomlkit.TOMLDocument,
) -> tuple[dict[str, Provider], dict[str, object]]:
) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]:
"""Parse provider entries from the document.
Returns:
(providers, bad_providers) — valid and invalid entries respectively.
(providers, bad_providers, recoverable_providers)
*providers* — ready to use (non-empty ``models``).
*bad_providers* — unparseable / unknown fields.
*recoverable_providers* — parsed OK but ``models`` empty; may be
promoted after a successful remote ``GET /models`` (or explicit model id).
"""
providers: dict[str, Provider] = {}
bad_providers: dict[str, object] = {}
recoverable: dict[str, Provider] = {}
raw: dict[str, object] = document.unwrap()
providers_raw: object = raw.get("providers", {})
if not isinstance(providers_raw, dict):
return providers, bad_providers
return providers, bad_providers, recoverable
for name, raw_entry in cast("dict[str, object]", providers_raw).items():
if not isinstance(raw_entry, dict):
bad_providers[name] = raw_entry
continue
entry = cast("dict[str, object]", raw_entry)
# Missing preset → OpenAI platform (Responses-capable defaults).
if "preset" not in entry:
entry = {**entry, "preset": "openai"}
# Try tagged-union dispatch via msgspec
try:
provider: Provider = msgspec.convert(raw_entry, Provider)
provider: Provider = msgspec.convert(entry, Provider)
except msgspec.ValidationError:
bad_providers[name] = raw_entry
continue
@@ -66,19 +77,20 @@ def _parse_providers(
cls = type(provider)
known = set(cls.__struct_fields__)
known.add("preset") # tag_field, excluded from __struct_fields__
if set(cast("dict[str, object]", raw_entry).keys()) - known:
# Validate against the entry we actually converted (may have injected preset).
if set(entry.keys()) - known:
bad_providers[name] = raw_entry
continue
# Usable providers must list at least one model (DeepSeek seeds defaults
# when models is omitted; an explicit empty models={} is invalid).
# Empty models: recoverable. OpenAI/DeepSeek seed defaults when
# ``models`` is omitted; only explicit models={} lands here.
if not provider.models:
bad_providers[name] = {**cast("dict[str, object]", raw_entry), "_reason": "no models"}
recoverable[name] = provider
continue
providers[name] = provider
return providers, bad_providers
return providers, bad_providers, recoverable
class ConfigStore:
@@ -90,6 +102,7 @@ class ConfigStore:
_agent: AgentConfig
_providers: dict[str, Provider]
_bad_providers: dict[str, object]
_recoverable_providers: dict[str, Provider]
def __init__(self, path: Path, document: tomlkit.TOMLDocument) -> None:
self._path = path
@@ -97,7 +110,7 @@ class ConfigStore:
raw: dict[str, object] = document.unwrap()
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._providers, self._bad_providers = _parse_providers(document)
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document)
@property
def path(self) -> Path:
@@ -135,9 +148,10 @@ class ConfigStore:
@providers.setter
def providers(self, value: Mapping[str, Provider]) -> None: # pyright: ignore[reportPropertyTypeMismatch]
"""Replace all providers."""
"""Replace all ready providers (clears recoverable/bad)."""
self._providers = dict(value)
self._bad_providers = {}
self._recoverable_providers = {}
# -- bad_providers (read-only) --
@@ -146,6 +160,46 @@ class ConfigStore:
"""Read-only view of unrecognised / malformed provider entries."""
return MappingProxyType(self._bad_providers)
@property
def recoverable_providers(self) -> MappingProxyType[str, Provider]:
"""Parsed providers with empty ``models`` (recoverable via remote list)."""
return MappingProxyType(self._recoverable_providers)
def selectable_providers(self) -> dict[str, Provider]:
"""Ready providers plus recoverable ones (ready wins on name clash)."""
return {**self._recoverable_providers, **self._providers}
def get_provider(self, name: str) -> Provider | None:
"""Look up a ready or recoverable provider by config name."""
if name in self._providers:
return self._providers[name]
return self._recoverable_providers.get(name)
def promote_provider(self, name: str, model_ids: Sequence[str]) -> Provider:
"""Seed ``models`` from *model_ids* and move recoverable → ready.
Also re-seeds an already-ready provider that somehow has empty models.
Does not write the TOML file (in-memory session only unless ``write()``).
"""
ids = [mid.strip() for mid in model_ids if mid and mid.strip()]
if not ids:
msg = f"cannot promote provider {name!r}: no model ids"
raise ValueError(msg)
if name in self._providers:
provider = self._providers[name]
elif name in self._recoverable_providers:
provider = self._recoverable_providers.pop(name)
else:
msg = f"unknown provider {name!r}"
raise KeyError(msg)
models = {mid: ModelConfig() for mid in ids}
promoted = msgspec.structs.replace(provider, models=models)
self._providers[name] = promoted
_ = self._bad_providers.pop(name, None)
return promoted
# -- persistence --
def write(self) -> None:
@@ -162,7 +216,7 @@ class ConfigStore:
raw: dict[str, object] = self._document.unwrap()
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._providers, self._bad_providers = _parse_providers(self._document)
self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document)
# -- internal sync helpers --
@@ -194,14 +248,15 @@ class ConfigStore:
self._sync_section("agent", self._agent)
def _sync_providers_section(self) -> None:
"""Sync ``[providers]`` to the document."""
"""Sync ``[providers]`` to the document (ready + recoverable)."""
section = self._toml_table("providers")
keep = set(self._providers) | set(self._recoverable_providers)
for name in list(section.keys()):
if name not in self._providers:
if name not in keep:
del section[name]
for name, provider in self._providers.items():
for name, provider in {**self._recoverable_providers, **self._providers}.items():
raw: dict[str, object] = msgspec.to_builtins(provider)
if name in section:
entry = cast("MutableMapping[str, object]", section[name])
+12
View File
@@ -0,0 +1,12 @@
from .client import OpenAIClient as OpenAIClient
from .model import Response as Response
from .model import ResponseDeleted as ResponseDeleted
from .model import ResponseFunctionTool as ResponseFunctionTool
from .model import ResponseFunctionToolCall as ResponseFunctionToolCall
from .model import ResponseFunctionToolCallOutput as ResponseFunctionToolCallOutput
from .model import ResponseOutputMessage as ResponseOutputMessage
from .model import ResponseOutputText as ResponseOutputText
from .model import ResponsesCreateParam as ResponsesCreateParam
from .model import ResponseStreamEvent as ResponseStreamEvent
from .model import response_function_calls as response_function_calls
from .model import response_output_text as response_output_text
+131
View File
@@ -0,0 +1,131 @@
"""OpenAI platform client: chat completions (compat) + Responses API."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import msgspec
from plyngent.lmproto.openai_compatible.client import BaseOpenAIClient
from plyngent.lmproto.openai_compatible.config import OpenAIConfig # noqa: TC001
from plyngent.lmproto.openai_compatible.model import ( # noqa: TC001
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
)
from .model import Response, ResponseDeleted, ResponsesCreateParam, ResponseStreamEvent
if TYPE_CHECKING:
from collections.abc import AsyncIterator
class OpenAIClient(BaseOpenAIClient):
"""Full OpenAI HTTP client (``/chat/completions`` + ``/responses`` + ``/models``)."""
response_decoder: msgspec.json.Decoder[Response]
response_deleted_decoder: msgspec.json.Decoder[ResponseDeleted]
response_event_decoder: msgspec.json.Decoder[ResponseStreamEvent]
def __init__(self, config: OpenAIConfig) -> None:
super().__init__(config)
self.response_decoder = msgspec.json.Decoder(Response)
self.response_deleted_decoder = msgspec.json.Decoder(ResponseDeleted)
self.response_event_decoder = msgspec.json.Decoder(ResponseStreamEvent)
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
) -> ChatCompletionResponse: ...
@overload
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: Literal[True]
) -> AsyncIterator[ChatCompletionChunk]: ...
async def chat_completions(
self, param: ChatCompletionsParam, *, stream: bool = False
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
param = msgspec.structs.replace(param, stream=stream)
data = self.encoder.encode(param)
if stream:
resp = await self.session.post(
"/chat/completions",
data=data,
headers={"Content-Type": "application/json"},
stream=True,
)
return self._parse_sse(resp)
resp = await self.session.post(
"/chat/completions",
data=data,
headers={"Content-Type": "application/json"},
stream=False,
)
body = await self._read_json_body(resp, what="chat completions")
return self.decoder.decode(body)
@overload
async def responses(self, param: ResponsesCreateParam, *, stream: Literal[False] = False) -> Response: ...
@overload
async def responses(
self, param: ResponsesCreateParam, *, stream: Literal[True]
) -> AsyncIterator[ResponseStreamEvent]: ...
async def responses(
self, param: ResponsesCreateParam, *, stream: bool = False
) -> Response | AsyncIterator[ResponseStreamEvent]:
"""Create a model response via OpenAI ``POST /responses``."""
param = msgspec.structs.replace(param, stream=stream)
data = self.encoder.encode(param)
if stream:
resp = await self.session.post(
"/responses",
data=data,
headers={"Content-Type": "application/json"},
stream=True,
)
return self._parse_response_sse(resp)
resp = await self.session.post(
"/responses",
data=data,
headers={"Content-Type": "application/json"},
stream=False,
)
body = await self._read_json_body(resp, what="responses")
return self.response_decoder.decode(body)
async def get_response(self, response_id: str) -> Response:
"""Retrieve a stored response via ``GET /responses/{id}``."""
resp = await self.session.get(f"/responses/{response_id}", stream=False)
body = await self._read_json_body(resp, what="responses")
return self.response_decoder.decode(body)
async def delete_response(self, response_id: str) -> ResponseDeleted:
"""Delete a stored response via ``DELETE /responses/{id}``."""
resp = await self.session.delete(f"/responses/{response_id}", stream=False)
body = await self._read_json_body(resp, what="responses")
return self.response_deleted_decoder.decode(body)
async def _parse_response_sse(self, resp: object) -> AsyncIterator[ResponseStreamEvent]:
"""Yield Responses API SSE events; stop at ``data: [DONE]``."""
from plyngent.lmproto.openai_compatible.client import (
close_async_response,
sse_data_payload,
)
typed = cast("Any", resp)
try:
await self._ensure_ok(typed, what="responses")
async for raw in typed.iter_lines():
if not raw:
continue
parsed = sse_data_payload(bytes(raw))
if parsed is None:
continue
if parsed is False:
break
yield self.response_event_decoder.decode(parsed)
finally:
await close_async_response(typed)
+325
View File
@@ -0,0 +1,325 @@
"""OpenAI Responses API (``POST /responses``) msgspec models.
Covers the common text + function-calling surface used by agents. Built-in
tools (web_search, file_search, …) may appear as extra fields on decode when
``omit_defaults`` / unknown fields allow; request construction for those is
left to callers via generic dicts where needed.
"""
from __future__ import annotations
from typing import Any, Literal, cast
import msgspec
from msgspec import UNSET, Struct, field
from plyngent.lmproto.openai_compatible.model import ServiceTier # noqa: TC001
from plyngent.typedef import JSONSchema, Unset # noqa: TC001
type ResponseStatus = Literal[
"completed",
"failed",
"in_progress",
"cancelled",
"queued",
"incomplete",
]
type ResponseItemStatus = Literal["in_progress", "completed", "incomplete"]
type ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"]
type TruncationMode = Literal["auto", "disabled"]
type PromptCacheRetention = Literal["in_memory", "24h"]
# --- Input / output content blocks -------------------------------------------------
class ResponseInputText(Struct, tag_field="type", tag="input_text"):
text: str
class ResponseOutputText(Struct, tag_field="type", tag="output_text"):
text: str
annotations: list[dict[str, Any]] = field(default_factory=list)
logprobs: list[dict[str, Any]] | Unset = UNSET
class ResponseOutputRefusal(Struct, tag_field="type", tag="refusal"):
refusal: str
type ResponseMessageContent = ResponseOutputText | ResponseOutputRefusal | ResponseInputText
# --- Message-shaped items (input or output) ----------------------------------------
class ResponseEasyInputMessage(Struct, tag_field="type", tag="message"):
"""Message item used in ``input`` (role + content string or parts)."""
role: Literal["system", "developer", "user", "assistant"]
content: str | list[dict[str, Any]]
# Output messages from the API also use type=message with id/status.
id: str | Unset = UNSET
status: ResponseItemStatus | Unset = UNSET
phase: Literal["commentary", "final_answer"] | Unset = UNSET
class ResponseOutputMessage(Struct, tag_field="type", tag="message"):
"""Assistant message item in ``response.output``."""
id: str
content: list[ResponseMessageContent]
role: Literal["assistant"] = "assistant"
status: ResponseItemStatus = "completed"
phase: Literal["commentary", "final_answer"] | Unset = UNSET
# --- Function calling (Responses shape: flat, not nested under ``function``) -------
class ResponseFunctionTool(Struct, tag_field="type", tag="function"):
"""Function tool definition for Responses ``tools`` array."""
name: str
description: str | Unset = UNSET
parameters: JSONSchema | Unset = UNSET
strict: bool | Unset = UNSET
class ResponseFunctionToolCall(Struct, tag_field="type", tag="function_call"):
"""Model-emitted function call item."""
call_id: str
name: str
arguments: str
id: str | Unset = UNSET
status: ResponseItemStatus | Unset = UNSET
namespace: str | Unset = UNSET
class ResponseFunctionToolCallOutput(Struct, tag_field="type", tag="function_call_output"):
"""Tool result item passed back in subsequent ``input``."""
call_id: str
output: str
id: str | Unset = UNSET
status: ResponseItemStatus | Unset = UNSET
# --- Reasoning item ----------------------------------------------------------------
class ResponseReasoningItem(Struct, tag_field="type", tag="reasoning"):
id: str
content: list[dict[str, Any]] = field(default_factory=list)
summary: list[dict[str, Any]] = field(default_factory=list)
status: ResponseItemStatus | Unset = UNSET
encrypted_content: str | Unset = UNSET
# --- Catch-all for other output item types (web_search_call, …) ---------------------
class ResponseUnknownItem(Struct):
"""Fallback for output/input items we do not model in full yet."""
type: str
id: str | Unset = UNSET
type ResponseOutputItem = (
ResponseOutputMessage
| ResponseFunctionToolCall
| ResponseReasoningItem
| ResponseFunctionToolCallOutput
| ResponseEasyInputMessage
)
# --- Request -----------------------------------------------------------------------
class ResponseReasoningConfig(Struct, omit_defaults=True):
effort: ReasoningEffort | Unset = UNSET
summary: Literal["auto", "concise", "detailed"] | Unset = UNSET
class ResponseTextFormatJsonSchema(Struct, tag_field="type", tag="json_schema"):
name: str
schema: JSONSchema
strict: bool | Unset = UNSET
description: str | Unset = UNSET
class ResponseTextFormatText(Struct, tag_field="type", tag="text"):
pass
class ResponseTextFormatJsonObject(Struct, tag_field="type", tag="json_object"):
pass
type ResponseTextFormat = ResponseTextFormatText | ResponseTextFormatJsonObject | ResponseTextFormatJsonSchema
class ResponseTextConfig(Struct, omit_defaults=True):
format: ResponseTextFormat | Unset = UNSET
verbosity: Literal["low", "medium", "high"] | Unset = UNSET
class ResponseStreamOptions(Struct, omit_defaults=True):
include_obfuscation: bool | Unset = UNSET
class ResponsesCreateParam(Struct, omit_defaults=True):
"""Body for ``POST /responses``."""
model: str
# string, or list of message / function_call_output / … items
input: str | list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]
instructions: str | Unset = UNSET
tools: list[ResponseFunctionTool | dict[str, Any]] | Unset = UNSET
tool_choice: str | dict[str, Any] | Unset = UNSET
parallel_tool_calls: bool | Unset = UNSET
previous_response_id: str | Unset = UNSET
store: bool | Unset = UNSET
stream: bool = False
stream_options: ResponseStreamOptions | Unset = UNSET
temperature: float | Unset = UNSET
top_p: float | Unset = UNSET
max_output_tokens: int | Unset = UNSET
max_tool_calls: int | Unset = UNSET
metadata: dict[str, str] | Unset = UNSET
reasoning: ResponseReasoningConfig | Unset = UNSET
text: ResponseTextConfig | Unset = UNSET
truncation: TruncationMode | Unset = UNSET
service_tier: ServiceTier | Unset = UNSET
user: str | Unset = UNSET
safety_identifier: str | Unset = UNSET
prompt_cache_key: str | Unset = UNSET
prompt_cache_retention: PromptCacheRetention | Unset = UNSET
include: list[str] | Unset = UNSET
background: bool | Unset = UNSET
# --- Response object ---------------------------------------------------------------
class ResponseIncompleteDetails(Struct, omit_defaults=True):
reason: Literal["max_output_tokens", "content_filter"] | Unset = UNSET
class ResponseError(Struct, omit_defaults=True):
code: str | Unset = UNSET
message: str | Unset = UNSET
class ResponseUsage(Struct, omit_defaults=True):
input_tokens: int | Unset = UNSET
output_tokens: int | Unset = UNSET
total_tokens: int | Unset = UNSET
input_tokens_details: dict[str, Any] | Unset = UNSET
output_tokens_details: dict[str, Any] | Unset = UNSET
class Response(Struct, omit_defaults=True):
"""``object: response`` from create/retrieve."""
id: str
created_at: float | int
model: str
# Keep items as dicts so unknown built-in tool item types still decode.
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
parallel_tool_calls: bool | Unset = UNSET
temperature: float | None | Unset = UNSET
top_p: float | None | Unset = 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
text: dict[str, Any] | Unset = UNSET
reasoning: dict[str, Any] | None | Unset = UNSET
store: bool | Unset = UNSET
user: str | None | Unset = UNSET
class ResponseDeleted(Struct):
id: str
object: Literal["response.deleted"] = "response.deleted"
deleted: bool = True
# --- Streaming events (subset) -----------------------------------------------------
class ResponseStreamEvent(Struct, omit_defaults=True):
"""Generic SSE event for Responses streaming.
OpenAI emits many event types; we keep common fields optional and let
callers branch on ``type``. Nested ``response`` is a dict so unknown
fields and evolving shapes still decode.
"""
type: str
response: dict[str, Any] | Unset = UNSET
item_id: str | Unset = UNSET
output_index: int | Unset = UNSET
content_index: int | Unset = UNSET
delta: str | Unset = UNSET
text: str | Unset = UNSET
item: dict[str, Any] | Unset = UNSET
sequence_number: int | Unset = UNSET
# function call argument streaming
name: str | Unset = UNSET
arguments: str | Unset = UNSET
call_id: str | Unset = UNSET
# --- Helpers -----------------------------------------------------------------------
def response_output_text(response: Response) -> str:
"""Concatenate all ``output_text`` blocks from message items (SDK-style helper)."""
parts: list[str] = []
for raw in response.output:
if raw.get("type") != "message":
continue
content: list[object] | None = raw.get("content")
if not isinstance(content, list):
continue
for block_obj in content:
if not isinstance(block_obj, dict):
continue
block_map = cast("dict[str, object]", block_obj)
if block_map.get("type") != "output_text":
continue
text = block_map.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts)
def response_function_calls(response: Response) -> list[ResponseFunctionToolCall]:
"""Decode ``function_call`` items from ``response.output``."""
result: list[ResponseFunctionToolCall] = []
for raw in response.output:
if raw.get("type") != "function_call":
continue
try:
result.append(msgspec_convert_function_call(raw))
except TypeError, ValueError, KeyError, msgspec.ValidationError:
continue
return result
def msgspec_convert_function_call(raw: dict[str, Any]) -> ResponseFunctionToolCall:
return msgspec.convert(raw, ResponseFunctionToolCall)
@@ -1,5 +1,6 @@
from .client import BaseOpenAIClient as BaseOpenAIClient
from .client import OpenAIClient as OpenAIClient
from .client import OpenAICompatibleClient as OpenAICompatibleClient
from .config import OpenAIConfig as OpenAIConfig
from .model import AnyAssistantToolCall as AnyAssistantToolCall
from .model import AnyChatMessage as AnyChatMessage
@@ -21,6 +22,8 @@ from .model import FinishReason as FinishReason
from .model import GrammarSyntax as GrammarSyntax
from .model import JsonObjectResponseFormat as JsonObjectResponseFormat
from .model import Modality as Modality
from .model import ModelObject as ModelObject
from .model import ModelsResponse as ModelsResponse
from .model import NamedChatMessage as NamedChatMessage
from .model import NamedRole as NamedRole
from .model import ReasoningEffort as ReasoningEffort
@@ -15,6 +15,7 @@ from .model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
ModelsResponse,
StreamToolCallDelta,
)
@@ -29,7 +30,7 @@ _HTTP_ERROR = 400
_ERROR_BODY_PREVIEW = 500
async def _close_async_response(resp: AsyncResponse) -> None:
async def close_async_response(resp: AsyncResponse) -> None:
"""Close a streaming response; support sync or async close()."""
for name in ("aclose", "close"):
method = getattr(resp, name, None)
@@ -41,6 +42,10 @@ async def _close_async_response(resp: AsyncResponse) -> None:
return
# Internal alias kept for older call sites in this module.
_close_async_response = close_async_response
async def read_response_body(resp: object) -> bytes | str | None:
"""Read response body; niquests ``AsyncResponse.content`` is an async property."""
content = getattr(resp, "content", None)
@@ -76,11 +81,16 @@ def sse_data_payload(line: bytes) -> bytes | None | Literal[False]:
return payload
def http_error_message(status_code: int, body: bytes | str | None = None) -> str | None:
def http_error_message(
status_code: int,
body: bytes | str | None = None,
*,
what: str = "request",
) -> str | None:
"""Return an error string for HTTP ``status_code`` >= 400, else ``None``."""
if status_code < _HTTP_ERROR:
return None
msg = f"chat completions HTTP {status_code}"
msg = f"{what} HTTP {status_code}"
if body is None:
return msg
if isinstance(body, (bytes, bytearray)):
@@ -91,10 +101,13 @@ def http_error_message(status_code: int, body: bytes | str | None = None) -> str
class BaseOpenAIClient:
"""Shared OpenAI-compatible HTTP surface (session, models, chat SSE)."""
session: AsyncSession
encoder: msgspec.json.Encoder
decoder: msgspec.json.Decoder[ChatCompletionResponse]
chunk_decoder: msgspec.json.Decoder[ChatCompletionChunk]
models_decoder: msgspec.json.Decoder[ModelsResponse]
def __init__(self, config: OpenAIConfig) -> None:
self.session = niquests.AsyncSession(
@@ -104,8 +117,20 @@ class BaseOpenAIClient:
self.encoder = msgspec.json.Encoder()
self.decoder = msgspec.json.Decoder(ChatCompletionResponse)
self.chunk_decoder = msgspec.json.Decoder(ChatCompletionChunk)
self.models_decoder = msgspec.json.Decoder(ModelsResponse)
async def _ensure_ok(self, resp: object) -> None:
async def models(self) -> list[str]:
"""List model ids via OpenAI-compatible ``GET /models``.
Returns sorted unique ``id`` values from the response ``data`` array.
"""
resp = await self.session.get("/models", stream=False)
body = await self._read_json_body(resp, what="models")
parsed = self.models_decoder.decode(body)
ids = {item.id for item in parsed.data if item.id}
return sorted(ids)
async def _ensure_ok(self, resp: object, *, what: str = "request") -> None:
"""Raise if the HTTP response is an error (stream or non-stream)."""
status = getattr(resp, "status_code", None)
if not isinstance(status, int):
@@ -115,7 +140,7 @@ class BaseOpenAIClient:
body: bytes | str | None = None
if status >= _HTTP_ERROR:
body = await read_response_body(resp)
msg = http_error_message(status, body)
msg = http_error_message(status, body, what=what)
if msg is None:
return
if hasattr(resp, "close") or hasattr(resp, "aclose"):
@@ -125,7 +150,7 @@ class BaseOpenAIClient:
async def _parse_sse(self, resp: AsyncResponse) -> AsyncIterator[ChatCompletionChunk]:
"""Yield SSE chunks; stop at ``data: [DONE]`` so we do not hang on keep-alive."""
try:
await self._ensure_ok(resp)
await self._ensure_ok(resp, what="chat completions")
async for raw in resp.iter_lines():
if not raw:
continue
@@ -138,8 +163,21 @@ class BaseOpenAIClient:
finally:
await _close_async_response(resp)
async def _read_json_body(self, resp: object, *, what: str) -> bytes:
await self._ensure_ok(resp, what=what)
body = await read_response_body(resp)
if body is None:
msg = f"{what} response body is empty"
raise RuntimeError(msg)
if not isinstance(body, (bytes, bytearray)):
msg = f"{what} response body has unexpected type {type(body)!r}"
raise TypeError(msg)
return bytes(body)
class OpenAICompatibleClient(BaseOpenAIClient):
"""Chat Completions only (``POST /chat/completions`` + ``GET /models``)."""
class OpenAIClient(BaseOpenAIClient):
def __init__(self, config: OpenAIConfig) -> None:
super().__init__(config)
@@ -173,15 +211,12 @@ class OpenAIClient(BaseOpenAIClient):
headers={"Content-Type": "application/json"},
stream=False,
)
await self._ensure_ok(resp)
body = await read_response_body(resp)
if body is None:
msg = "chat completions response body is empty"
raise RuntimeError(msg)
if not isinstance(body, (bytes, bytearray)):
msg = f"chat completions response body has unexpected type {type(body)!r}"
raise TypeError(msg)
return self.decoder.decode(bytes(body))
body = await self._read_json_body(resp, what="chat completions")
return self.decoder.decode(body)
# Backward-compatible alias: many call sites still import OpenAIClient from this package.
OpenAIClient = OpenAICompatibleClient
def merge_stream_tool_calls(deltas: list[StreamToolCallDelta]) -> list[AssistantFunctionToolCall]:
@@ -258,3 +258,19 @@ class ChatCompletionChunk(Struct):
choices: list[ChunkChoice]
# Final usage chunk may omit or null usage depending on provider.
usage: dict[str, Any] | None | Unset = UNSET
class ModelObject(Struct):
"""One entry from OpenAI-compatible ``GET /models``."""
id: str
object: Literal["model"] | Unset = UNSET
created: int | Unset = UNSET
owned_by: str | Unset = UNSET
class ModelsResponse(Struct):
"""OpenAI-compatible ``GET /models`` list body."""
data: list[ModelObject]
object: Literal["list"] | Unset = UNSET
+18 -5
View File
@@ -9,15 +9,20 @@ from plyngent.config.models import (
Provider,
)
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
from plyngent.lmproto.openai_compatible import OpenAIClient, OpenAIConfig
from plyngent.lmproto.openai import OpenAIClient
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig
if TYPE_CHECKING:
from collections.abc import Mapping
from plyngent.agent.responses_client import ResponsesChatClient
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
type OpenAICompatibleClient = OpenAIClient | DeepseekOpenAIClient
type ProtocolClient = OpenAIClient | OpenAICompatibleClient | DeepseekOpenAIClient | ResponsesChatClient
# Backward-compatible name used by older imports/tests.
type OpenAICompatibleClientUnion = ProtocolClient
class ProviderNotSupportedError(NotImplementedError):
@@ -45,17 +50,25 @@ def _deepseek_convention(extras: Mapping[str, str]) -> str:
return extras.get("convention", "openai").lower()
def create_client(provider: Provider) -> OpenAICompatibleClient:
def create_client(provider: Provider) -> ProtocolClient:
"""Build a protocol client for the given provider config entry.
OpenAI platform providers are wrapped so the agent uses the Responses API
while still exposing a chat-completions-shaped interface.
Raises:
ProviderNotSupportedError: When the provider preset (or DeepSeek convention)
has no implemented client yet.
"""
if isinstance(provider, OpenAIProvider):
return OpenAIClient(provider_to_openai_config(provider))
from plyngent.agent.responses_client import wrap_openai_for_agent
return wrap_openai_for_agent(
OpenAIClient(provider_to_openai_config(provider)),
provider_tools=provider.provider_tools or None,
)
if isinstance(provider, OpenAICompatibleProvider):
return OpenAIClient(provider_to_openai_config(provider))
return OpenAICompatibleClient(provider_to_openai_config(provider))
if isinstance(provider, DeepseekProvider):
convention = _deepseek_convention(provider.extras)
if convention in {"openai", "openai_compat", "openai-compatible"}:
+2 -2
View File
@@ -14,9 +14,9 @@ DEFAULT_MAX_MATCHES = 200
def _rel(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root))
return path.relative_to(root).as_posix()
except ValueError:
return str(path)
return path.as_posix()
def _hidden_or_vcs(parts: tuple[str, ...]) -> bool:
+4 -3
View File
@@ -10,10 +10,11 @@ from .pty_session import PtyManager
@tool
def open_pty(command: list[str], *, cwd: str = ".") -> str:
"""Open a Unix PTY session running ``command`` (argv) under the workspace.
"""Open a PTY session running ``command`` (argv) under the workspace.
Returns structured status including session_id. Not supported on Windows.
Failed exec surfaces via later read_pty data (marker) and exit_code=127.
POSIX uses openpty/fork; Windows uses ConPTY (pywinpty). Returns structured
status including session_id. Failed exec may surface via later read_pty
data (marker) and a non-zero exit_code.
"""
try:
session = PtyManager.open(command, cwd=cwd)
+287
View File
@@ -0,0 +1,287 @@
"""Platform PTY backends (POSIX openpty/fork; Windows ConPTY via pywinpty)."""
from __future__ import annotations
import contextlib
import os
import select
import signal
import sys
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from pathlib import Path
@runtime_checkable
class PtyHandle(Protocol):
"""One live PTY-backed child process."""
@property
def master_fd(self) -> int | None:
"""POSIX master FD when applicable; ``None`` on Windows ConPTY."""
...
@property
def pid(self) -> int | None:
"""POSIX child pid when applicable; ``None`` on Windows ConPTY."""
...
def poll_exit(self) -> tuple[bool, int | None]:
"""Return ``(alive, exit_code)`` without blocking."""
...
def read_bytes(self, max_bytes: int, timeout: float) -> bytes:
"""Read up to *max_bytes* waiting up to *timeout* seconds."""
...
def write_bytes(self, data: bytes) -> None:
"""Write raw bytes to the PTY input."""
...
def terminate(self) -> None:
"""Request graceful shutdown (SIGTERM / TerminateProcess soft)."""
...
def kill(self) -> None:
"""Force-kill the child."""
...
def wait_reap(self, *, timeout: float | None = None) -> None:
"""Wait for the child to exit and reap it."""
...
def close_resources(self) -> None:
"""Release OS handles/FDs (after terminate/kill)."""
...
_EXEC_FAIL_MARKER = b"plyngent-pty-exec-failed: "
_STDERR_FD = 2
@dataclass
class PosixPtyHandle:
master_fd: int
pid: int
_alive: bool = True
_exit_code: int | None = None
_closed: bool = False
def poll_exit(self) -> tuple[bool, int | None]:
if not self._alive or self._closed:
return self._alive, self._exit_code
try:
waited_pid, status = os.waitpid(self.pid, os.WNOHANG)
except ChildProcessError:
self._alive = False
return False, self._exit_code
if waited_pid == 0:
return True, None
self._alive = False
if os.WIFEXITED(status):
self._exit_code = os.WEXITSTATUS(status)
elif os.WIFSIGNALED(status):
self._exit_code = -os.WTERMSIG(status)
else:
self._exit_code = status
return False, self._exit_code
def read_bytes(self, max_bytes: int, timeout: float) -> bytes:
if self._closed or max_bytes < 1:
return b""
data = b""
try:
ready, _, _ = select.select([self.master_fd], [], [], timeout)
if ready:
data = os.read(self.master_fd, max_bytes)
except OSError, ValueError:
data = b""
_ = self.poll_exit()
return data
def write_bytes(self, data: bytes) -> None:
_ = os.write(self.master_fd, data)
def terminate(self) -> None:
if not self._alive:
return
with contextlib.suppress(ProcessLookupError):
os.kill(self.pid, signal.SIGTERM)
def kill(self) -> None:
if not self._alive:
return
with contextlib.suppress(ProcessLookupError):
os.kill(self.pid, signal.SIGKILL)
with contextlib.suppress(ChildProcessError):
_ = os.waitpid(self.pid, 0)
self._alive = False
if self._exit_code is None:
self._exit_code = -signal.SIGKILL
def wait_reap(self, *, timeout: float | None = None) -> None:
deadline = None if timeout is None else time.monotonic() + max(0.0, timeout)
while self._alive:
_ = self.poll_exit()
if not self._alive:
with contextlib.suppress(ChildProcessError):
_ = os.waitpid(self.pid, os.WNOHANG)
return
if deadline is not None and time.monotonic() >= deadline:
return
time.sleep(0.05)
def close_resources(self) -> None:
if self._closed:
return
with contextlib.suppress(OSError):
os.close(self.master_fd)
self._closed = True
if sys.platform == "win32":
from winpty import PtyProcess
@dataclass
class WinPtyHandle:
"""ConPTY session via pywinpty ``PtyProcess``."""
process: PtyProcess
_alive: bool = True
_exit_code: int | None = None
_closed: bool = False
@property
def master_fd(self) -> int | None:
return None
@property
def pid(self) -> int | None:
return None
def poll_exit(self) -> tuple[bool, int | None]:
if not self._alive or self._closed:
return self._alive, self._exit_code
try:
alive = bool(self.process.isalive())
except Exception: # noqa: BLE001 — treat as dead on probe failure
alive = False
if alive:
return True, None
self._alive = False
status = self.process.exitstatus
if isinstance(status, int):
self._exit_code = status
return False, self._exit_code
def read_bytes(self, max_bytes: int, timeout: float) -> bytes:
"""Read up to *max_bytes*.
pywinpty ``PtyProcess.read(size)`` is blocking and has no timeout kwarg;
*timeout* is accepted for API parity with the POSIX backend.
"""
del timeout # ConPTY read has no timed wait in pywinpty 3.x
if self._closed or max_bytes < 1:
return b""
try:
raw = self.process.read(max_bytes)
except Exception: # noqa: BLE001 — EOF / closed PTY
_ = self.poll_exit()
return b""
data = raw.encode("utf-8", errors="replace")
if len(data) > max_bytes:
data = data[:max_bytes]
_ = self.poll_exit()
return data
def write_bytes(self, data: bytes) -> None:
text = data.decode("utf-8", errors="replace")
_ = self.process.write(text)
def terminate(self) -> None:
if not self._alive:
return
with contextlib.suppress(Exception):
_ = self.process.terminate(force=False)
def kill(self) -> None:
with contextlib.suppress(Exception):
_ = self.process.terminate(force=True)
with contextlib.suppress(Exception):
self.process.kill()
self._alive = False
if self._exit_code is None:
self._exit_code = 1
def wait_reap(self, *, timeout: float | None = None) -> None:
deadline = None if timeout is None else time.monotonic() + max(0.0, timeout)
while self._alive:
_ = self.poll_exit()
if not self._alive:
return
if deadline is not None and time.monotonic() >= deadline:
return
time.sleep(0.05)
def close_resources(self) -> None:
if self._closed:
return
with contextlib.suppress(Exception):
self.process.close(force=True)
self._closed = True
def pty_available() -> bool:
return True
def spawn_pty(command: list[str], *, cwd: Path) -> PtyHandle:
if not command:
msg = "command argv must not be empty"
raise OSError(msg)
try:
process = PtyProcess.spawn(command, cwd=str(cwd))
except Exception as exc:
msg = f"failed to open ConPTY: {exc}"
raise OSError(msg) from exc
return WinPtyHandle(process=process)
else:
def pty_available() -> bool:
return hasattr(os, "fork")
def spawn_pty(command: list[str], *, cwd: Path) -> PtyHandle:
if not command:
msg = "command argv must not be empty"
raise OSError(msg)
return _spawn_posix(command, cwd=cwd)
def _spawn_posix(command: list[str], *, cwd: Path) -> PosixPtyHandle:
import pty
master_fd, slave_fd = pty.openpty()
with contextlib.suppress(OSError, AttributeError):
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
pid = os.fork()
if pid == 0: # child — fork-then-exec only
try:
os.close(master_fd)
_ = os.setsid()
_ = os.dup2(slave_fd, 0)
_ = os.dup2(slave_fd, 1)
_ = os.dup2(slave_fd, _STDERR_FD)
if slave_fd > _STDERR_FD:
os.close(slave_fd)
_ = os.chdir(cwd)
os.execvp(command[0], command)
except OSError as exc:
with contextlib.suppress(OSError):
_ = os.write(1, _EXEC_FAIL_MARKER + str(exc).encode(errors="replace") + b"\n")
os._exit(127)
os.close(slave_fd)
with contextlib.suppress(OSError, AttributeError):
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
return PosixPtyHandle(master_fd=master_fd, pid=pid)
+38 -75
View File
@@ -1,17 +1,13 @@
"""Unix PTY session registry (requires ``pty`` + ``os.fork``; not supported on Windows)."""
"""Cross-platform PTY session registry (POSIX openpty/fork; Windows ConPTY)."""
from __future__ import annotations
import contextlib
import os
import pty
import select
import signal
import time
from dataclasses import dataclass, field
from threading import Lock
from typing import TYPE_CHECKING, ClassVar
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
if TYPE_CHECKING:
@@ -27,15 +23,12 @@ DEFAULT_SESSION_OUTPUT_BUDGET = 256_000
DEFAULT_CLOSE_GRACE_SECONDS = 0.5
_SESSION_LIMIT_STEP = 4
_BUDGET_STEP = 256_000
_STDERR_FD = 2
_EXEC_FAIL_MARKER = b"plyngent-pty-exec-failed: "
@dataclass
class PtySession:
session_id: int
master_fd: int
pid: int
handle: PtyHandle
closed: bool = False
alive: bool = True
exit_code: int | None = None
@@ -45,6 +38,16 @@ class PtySession:
output_budget: int = DEFAULT_SESSION_OUTPUT_BUDGET
command: tuple[str, ...] = ()
@property
def master_fd(self) -> int | None:
"""POSIX master FD when available (``None`` on Windows ConPTY)."""
return self.handle.master_fd
@property
def pid(self) -> int | None:
"""POSIX child pid when available (``None`` on Windows ConPTY)."""
return self.handle.pid
@dataclass(frozen=True)
class PtyReadResult:
@@ -114,6 +117,9 @@ class PtyManager:
*,
cwd: str = ".",
) -> PtySession:
if not pty_available():
msg = "PTY is not available on this platform (Windows needs pywinpty)"
raise WorkspaceError(msg)
check_command_allowed(command)
workdir = resolve_path(cwd)
if not workdir.is_dir():
@@ -131,37 +137,18 @@ class PtyManager:
msg = f"{reason}; close idle sessions or allow a higher limit"
raise WorkspaceError(msg)
master_fd, slave_fd = pty.openpty()
# Parent must not pass master into the child (or future children).
with contextlib.suppress(OSError, AttributeError):
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
pid = os.fork()
if pid == 0: # child — keep path fork-then-exec only (no Python after exec fail except _exit)
try:
os.close(master_fd)
_ = os.setsid()
_ = os.dup2(slave_fd, 0)
_ = os.dup2(slave_fd, 1)
_ = os.dup2(slave_fd, _STDERR_FD)
if slave_fd > _STDERR_FD:
os.close(slave_fd)
_ = os.chdir(workdir)
os.execvp(command[0], command)
except OSError as exc:
with contextlib.suppress(OSError):
_ = os.write(1, _EXEC_FAIL_MARKER + str(exc).encode(errors="replace") + b"\n")
os._exit(127)
try:
handle = spawn_pty(command, cwd=workdir)
except OSError as exc:
msg = f"failed to open PTY: {exc}"
raise WorkspaceError(msg) from exc
os.close(slave_fd)
with contextlib.suppress(OSError, AttributeError):
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
with cls._lock:
session_id = cls._next_id
cls._next_id += 1
session = PtySession(
session_id=session_id,
master_fd=master_fd,
pid=pid,
handle=handle,
command=tuple(command),
output_budget=cls.session_output_budget,
)
@@ -181,20 +168,10 @@ class PtyManager:
def _poll_exit(cls, session: PtySession) -> None:
if not session.alive or session.closed:
return
try:
waited_pid, status = os.waitpid(session.pid, os.WNOHANG)
except ChildProcessError:
session.alive = False
return
if waited_pid == 0:
return
session.alive = False
if os.WIFEXITED(status):
session.exit_code = os.WEXITSTATUS(status)
elif os.WIFSIGNALED(status):
session.exit_code = -os.WTERMSIG(status)
else:
session.exit_code = status
alive, exit_code = session.handle.poll_exit()
session.alive = alive
if exit_code is not None:
session.exit_code = exit_code
@classmethod
def refresh(cls, session_id: int) -> PtySession:
@@ -210,13 +187,7 @@ class PtyManager:
if session.closed or (session.output_budget - session.bytes_read) <= 0:
return b""
to_read = min(max_bytes, session.output_budget - session.bytes_read)
data = b""
try:
ready, _, _ = select.select([session.master_fd], [], [], timeout)
if ready:
data = os.read(session.master_fd, to_read)
except OSError, ValueError:
data = b""
data = session.handle.read_bytes(to_read, timeout)
cls._poll_exit(session)
if not data:
return b""
@@ -336,7 +307,7 @@ class PtyManager:
msg = f"PTY session process is not alive: {session_id}"
raise WorkspaceError(msg)
try:
_ = os.write(session.master_fd, data.encode())
session.handle.write_bytes(data.encode())
except OSError as exc:
cls._poll_exit(session)
msg = f"failed to write PTY: {exc}"
@@ -366,27 +337,19 @@ class PtyManager:
cls._poll_exit(session)
if session.alive:
with contextlib.suppress(ProcessLookupError):
os.kill(session.pid, signal.SIGTERM)
deadline = time.monotonic() + max(0.0, grace_seconds)
while session.alive and time.monotonic() < deadline:
time.sleep(0.05)
cls._poll_exit(session)
session.handle.terminate()
session.handle.wait_reap(timeout=max(0.0, grace_seconds))
cls._poll_exit(session)
if session.alive:
with contextlib.suppress(ProcessLookupError):
os.kill(session.pid, signal.SIGKILL)
with contextlib.suppress(ChildProcessError):
_ = os.waitpid(session.pid, 0)
session.alive = False
session.handle.kill()
session.handle.wait_reap(timeout=1.0)
cls._poll_exit(session)
if session.alive:
session.alive = False
if session.exit_code is None:
session.exit_code = -signal.SIGKILL
else:
# Ensure reaped
with contextlib.suppress(ChildProcessError):
_ = os.waitpid(session.pid, os.WNOHANG)
session.exit_code = session.handle.poll_exit()[1]
with contextlib.suppress(OSError):
os.close(session.master_fd)
session.handle.close_resources()
session.closed = True
with cls._lock:
_ = cls._sessions.pop(session_id, None)
+3 -2
View File
@@ -94,9 +94,10 @@ def resolve_path(path: str | Path) -> Path:
except ValueError as exc:
msg = f"path escapes workspace root ({root}): {path}"
raise WorkspaceError(msg) from exc
resolved_str = str(resolved)
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
resolved_str = str(resolved).replace("\\", "/")
for pattern in _state.path_denylist:
if pattern and pattern in resolved_str:
if pattern and pattern.replace("\\", "/") in resolved_str:
msg = f"path denied by policy (matched {pattern!r}): {path}"
raise WorkspaceError(msg)
return resolved
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
import msgspec
import pytest
from plyngent.agent.responses_bridge import (
chat_messages_to_responses_input,
chat_param_to_responses_kwargs,
response_to_assistant_message,
response_to_chat_completion,
tool_items_to_response_tools,
)
from plyngent.agent.responses_client import ResponsesChatClient
from plyngent.lmproto.openai.model import Response, ResponsesCreateParam
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionsParam,
SystemChatMessage,
ToolChatMessage,
ToolFunction,
ToolFunctionItem,
UserChatMessage,
)
def test_tool_items_to_response_tools() -> None:
items = [
ToolFunctionItem(
function=ToolFunction(
name="read_file",
description="Read a file",
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
)
)
]
tools = tool_items_to_response_tools(items)
assert len(tools) == 1
assert tools[0].name == "read_file"
def test_chat_messages_to_input_and_instructions() -> None:
messages = [
SystemChatMessage(content="You are helpful."),
UserChatMessage(content="hi"),
AssistantChatMessage(
content=None,
tool_calls=[
AssistantFunctionToolCall(
id="call_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
ToolChatMessage(content="file body", tool_call_id="call_1"),
]
instructions, items = chat_messages_to_responses_input(messages)
assert instructions == "You are helpful."
assert len(items) == 3 # user, function_call, function_call_output
def test_response_to_assistant_with_tools() -> None:
raw = {
"id": "resp_1",
"object": "response",
"created_at": 1,
"model": "gpt-test",
"status": "completed",
"output": [
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "done", "annotations": []}],
},
{
"type": "function_call",
"call_id": "call_9",
"name": "add",
"arguments": '{"a":1}',
"status": "completed",
},
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
response = msgspec.convert(raw, Response)
from msgspec import UNSET
assistant = response_to_assistant_message(response)
assert assistant.content == "done"
assert assistant.tool_calls is not UNSET
assert isinstance(assistant.tool_calls, list)
call0 = assistant.tool_calls[0]
assert isinstance(call0, AssistantFunctionToolCall)
assert call0.id == "call_9"
assert call0.function.name == "add"
completion = response_to_chat_completion(response)
assert completion.choices[0].message.content == "done"
assert isinstance(completion.usage, dict)
assert completion.usage["input_tokens"] == 10
def test_chat_param_to_responses_kwargs() -> None:
param = ChatCompletionsParam(
model="gpt-test",
messages=[SystemChatMessage(content="sys"), UserChatMessage(content="hi")],
tools=[ToolFunctionItem(function=ToolFunction(name="t", parameters={"type": "object"}))],
temperature=0.2,
)
kwargs = chat_param_to_responses_kwargs(param)
assert kwargs["model"] == "gpt-test"
assert kwargs["instructions"] == "sys"
assert kwargs["store"] is False
assert kwargs["temperature"] == 0.2
assert len(kwargs["tools"]) == 1
def test_provider_tools_merged_after_local_functions() -> None:
param = ChatCompletionsParam(
model="gpt-test",
messages=[UserChatMessage(content="hi")],
tools=[ToolFunctionItem(function=ToolFunction(name="local_tool", parameters={"type": "object"}))],
)
kwargs = chat_param_to_responses_kwargs(
param,
provider_tools=[{"type": "web_search"}, {"type": "file_search", "vector_store_ids": ["vs_1"]}],
)
tools = kwargs["tools"]
assert len(tools) == 3
assert tools[0].name == "local_tool"
assert tools[1]["type"] == "web_search"
assert tools[2]["vector_store_ids"] == ["vs_1"]
@pytest.mark.asyncio
async def test_responses_chat_client_non_stream(monkeypatch: pytest.MonkeyPatch) -> None:
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
platform = OpenAIClient(OpenAIConfig(access_key_or_token="sk", base_url="https://example/v1"))
body = {
"id": "resp_x",
"object": "response",
"created_at": 1,
"model": "gpt-test",
"status": "completed",
"output": [
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "hello", "annotations": []}],
}
],
"usage": {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4},
}
async def fake_responses(param: ResponsesCreateParam, *, stream: bool = False):
assert stream is False
assert param.model == "gpt-test"
assert param.store is False
return msgspec.convert(body, Response)
monkeypatch.setattr(platform, "responses", fake_responses)
client = ResponsesChatClient(platform)
result = await client.chat_completions(
ChatCompletionsParam(model="gpt-test", messages=[UserChatMessage(content="hi")]),
stream=False,
)
assert result.choices[0].message.content == "hello"
+8
View File
@@ -50,6 +50,14 @@ def test_token_usage_from_api_infers_total() -> None:
assert u.total_tokens == 5
def test_token_usage_from_api_responses_fields() -> None:
u = token_usage_from_api({"input_tokens": 7, "output_tokens": 2, "total_tokens": 9})
assert u is not None
assert u.prompt_tokens == 7
assert u.completion_tokens == 2
assert u.total_tokens == 9
def test_chars_to_tokens() -> None:
assert chars_to_tokens(0) == 0
assert chars_to_tokens(1) == 1
+57 -1
View File
@@ -3,7 +3,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import ReasoningDeltaEvent, TextDeltaEvent, ToolResultEvent
from plyngent.cli.display import render_events, set_verbose_tool_results
from plyngent.cli.display import (
get_markdown_enabled,
markdown_render_available,
print_markdown,
render_events,
set_markdown_enabled,
set_verbose_tool_results,
)
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
if TYPE_CHECKING:
@@ -20,6 +27,7 @@ async def _aiter(events: list[AgentEvent]) -> AsyncIterator[AgentEvent]:
async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) -> None:
set_markdown_enabled(False)
await render_events(
_aiter(
[
@@ -33,12 +41,14 @@ async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) ->
assert "think" in out
assert "assistant: " in out
assert "hello" in out
set_markdown_enabled(True)
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
long = "x" * 200
msg = ToolChatMessage(content=long, tool_call_id="1")
set_verbose_tool_results(False)
set_markdown_enabled(False)
await render_events(_aiter([ToolResultEvent(message=msg)]))
out = capsys.readouterr().out
assert "…" in out
@@ -49,3 +59,49 @@ async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]
out2 = capsys.readouterr().out
assert long in out2
set_verbose_tool_results(False)
set_markdown_enabled(True)
async def test_markdown_off_keeps_plain_stream(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
set_markdown_enabled(False)
await render_events(_aiter([TextDeltaEvent(content="**bold**")]))
out = capsys.readouterr().out
assert "**bold**" in out
set_markdown_enabled(True)
async def test_markdown_on_replaces_with_rich(
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("plyngent.cli.display.markdown_render_available", lambda: True)
set_markdown_enabled(True)
await render_events(_aiter([TextDeltaEvent(content="hello **world**")]), markdown=True)
out = capsys.readouterr().out
# Rich markdown renders emphasis; raw ** markers should not remain as the sole form.
assert "assistant:" in out or "assistant: " in out
assert "world" in out
def test_print_markdown_renders(capsys: pytest.CaptureFixture[str]) -> None:
print_markdown("# Title\n\n`code`", label="assistant: ")
out = capsys.readouterr().out
assert "Title" in out
assert "code" in out
def test_markdown_flags_roundtrip() -> None:
set_markdown_enabled(False)
assert get_markdown_enabled() is False
set_markdown_enabled(True)
assert get_markdown_enabled() is True
def test_markdown_render_available_respects_plain_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PLYNGENT_PLAIN", "1")
assert markdown_render_available() is False
monkeypatch.delenv("PLYNGENT_PLAIN", raising=False)
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import pytest
from plyngent.cli.models_source import (
client_supports_models,
config_model_ids,
fetch_remote_model_ids,
merge_model_choices,
model_choices_for_provider,
)
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider
def test_merge_model_choices_union() -> None:
assert merge_model_choices(["b", "a"], ["a", "c"]) == ["a", "b", "c"]
assert merge_model_choices(["a"], None) == ["a"]
assert merge_model_choices([], ["z"]) == ["z"]
def test_model_choices_for_provider() -> None:
provider = OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"cfg": ModelConfig()},
)
assert config_model_ids(provider) == ["cfg"]
assert model_choices_for_provider(provider, remote_ids=["remote", "cfg"]) == ["cfg", "remote"]
def test_client_supports_models() -> None:
class Ok:
async def models(self) -> list[str]:
return ["m"]
class No:
pass
assert client_supports_models(Ok())
assert not client_supports_models(No())
@pytest.mark.asyncio
async def test_fetch_remote_model_ids() -> None:
class Ok:
async def models(self) -> list[str]:
return ["z", "a"]
assert await fetch_remote_model_ids(Ok()) == ["z", "a"]
with pytest.raises(TypeError, match="does not support"):
_ = await fetch_remote_model_ids(object())
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from pathlib import Path
import pytest
import plyngent.config
from plyngent.cli.provider_recovery import ensure_provider_ready, try_promote_provider
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider
def _hollow_store(tmp_path: Path):
path = tmp_path / "cfg.toml"
_ = path.write_text(
"""
[providers.hollow]
preset = "openai-compatible"
url = "https://example.com/v1"
access_key_or_token = "sk-test"
models = {}
""",
encoding="utf-8",
)
return plyngent.config.load(path)
@pytest.mark.asyncio
async def test_try_promote_with_seed(tmp_path: Path) -> None:
store = _hollow_store(tmp_path)
promoted = await try_promote_provider(store, "hollow", seed_model_ids=["gpt-x"])
assert promoted is not None
assert "gpt-x" in promoted.models
assert "hollow" in store.providers
assert "hollow" not in store.recoverable_providers
@pytest.mark.asyncio
async def test_try_promote_via_remote(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
store = _hollow_store(tmp_path)
async def fake_discover(provider: object) -> list[str]:
del provider
return ["remote-a", "remote-b"]
monkeypatch.setattr("plyngent.cli.provider_recovery.discover_model_ids", fake_discover)
promoted = await try_promote_provider(store, "hollow")
assert promoted is not None
assert set(promoted.models) == {"remote-a", "remote-b"}
@pytest.mark.asyncio
async def test_ensure_provider_ready_already_ready(tmp_path: Path) -> None:
store = _hollow_store(tmp_path)
ready = OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"m": ModelConfig()},
)
store.providers = {"ready": ready}
out = await ensure_provider_ready(store, "ready", ready, interactive=False)
assert out is ready
@pytest.mark.asyncio
async def test_ensure_provider_ready_seed_model(tmp_path: Path) -> None:
store = _hollow_store(tmp_path)
provider = store.recoverable_providers["hollow"]
out = await ensure_provider_ready(
store,
"hollow",
provider,
preferred_model="explicit",
interactive=False,
)
assert "explicit" in out.models
+8
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import importlib.util
import pytest
import tomlkit
from plyngent.cli.readline_setup import (
@@ -12,6 +15,11 @@ from plyngent.cli.state import ReplState
from plyngent.config.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
from plyngent.config.store import ConfigStore
pytestmark = pytest.mark.skipif(
importlib.util.find_spec("readline") is None,
reason="readline not available (e.g. Windows/Wine)",
)
def _minimal_state(tmp_path: object) -> ReplState:
from pathlib import Path
+1 -1
View File
@@ -235,7 +235,7 @@ async def test_provider_switch_prompts_when_model_missing(
# When switching to b, only-a is missing → select_model is invoked interactively.
monkeypatch.setattr(
"plyngent.cli.slash.select_model",
lambda provider, preferred=None, interactive=True: "only-b",
lambda provider, preferred=None, interactive=True, choices=None: "only-b",
)
assert await handle_slash(state, "/provider b") is True
assert state.provider_name == "b"
+17 -3
View File
@@ -64,11 +64,25 @@ def test_select_provider_interactive_choose() -> None:
assert name == "b"
def test_select_model_when_preferred_missing_raises() -> None:
def test_select_model_preferred_not_in_config_allowed() -> None:
"""Explicit model ids are accepted; the API validates at chat time."""
provider = OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"m1": ModelConfig()},
)
with pytest.raises(Exception, match="unknown model"):
_ = select_model(provider, preferred="nope")
assert select_model(provider, preferred="nope") == "nope"
assert select_model(provider, preferred=" custom ") == "custom"
def test_select_model_choices_override() -> None:
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
provider = OpenAICompatibleProvider(
access_key_or_token="sk",
url="https://x/v1",
models={"m1": ModelConfig()},
)
with temporary_backend(ScriptedBackend(["remote-x"])):
assert select_model(provider, choices=["remote-x", "m1"]) == "remote-x"
+51 -2
View File
@@ -58,6 +58,33 @@ def test_deepseek_default_models_on_construct() -> None:
assert set(provider.models) == {"deepseek-v4-flash", "deepseek-v4-pro"}
def test_openai_default_models_on_construct() -> None:
provider = OpenAIProvider(access_key_or_token="sk-test")
assert set(provider.models) == {"gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano"}
assert provider.provider_tools == [{"type": "web_search"}]
def test_openai_explicit_empty_provider_tools() -> None:
provider = OpenAIProvider(access_key_or_token="sk-test", provider_tools=[])
assert provider.provider_tools == []
def test_openai_omitted_preset_and_models_from_toml(tmp_path: Path) -> None:
path = tmp_path / "openai-defaults.toml"
_ = path.write_text(
"""
[providers.oai]
access_key_or_token = "sk-test"
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
assert "oai" in config.providers
provider = config.providers["oai"]
assert isinstance(provider, OpenAIProvider)
assert set(provider.models) == {"gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano"}
def test_deepseek_explicit_models_override_defaults() -> None:
from plyngent.config import ModelConfig
@@ -81,7 +108,7 @@ def test_read_bad_config() -> None:
assert isinstance(config.bad_providers, Mapping)
def test_provider_with_empty_models_is_bad(tmp_path: Path) -> None:
def test_provider_with_empty_models_is_recoverable(tmp_path: Path) -> None:
path = tmp_path / "empty-models.toml"
_ = path.write_text(
"""
@@ -95,7 +122,29 @@ models = {}
)
config = plyngent.config.load(path)
assert "hollow" not in config.providers
assert "hollow" in config.bad_providers
assert "hollow" not in config.bad_providers
assert "hollow" in config.recoverable_providers
promoted = config.promote_provider("hollow", ["m1", "m2"])
assert "hollow" in config.providers
assert "hollow" not in config.recoverable_providers
assert set(promoted.models) == {"m1", "m2"}
def test_promote_provider_requires_ids(tmp_path: Path) -> None:
path = tmp_path / "empty-models.toml"
_ = path.write_text(
"""
[providers.hollow]
preset = "openai-compatible"
url = "https://example.com/v1"
access_key_or_token = "sk-test"
models = {}
""",
encoding="utf-8",
)
config = plyngent.config.load(path)
with pytest.raises(ValueError, match="no model ids"):
_ = config.promote_provider("hollow", [])
def test_read_invalid_config() -> None:
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import msgspec
import pytest
from plyngent.lmproto.openai_compatible.client import OpenAIClient
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
from plyngent.lmproto.openai_compatible.model import ModelObject, ModelsResponse
def test_models_response_decode() -> None:
raw = b'{"object":"list","data":[{"id":"b","object":"model"},{"id":"a","owned_by":"x"}]}'
parsed = msgspec.json.decode(raw, type=ModelsResponse)
assert [m.id for m in parsed.data] == ["b", "a"]
assert isinstance(parsed.data[0], ModelObject)
@pytest.mark.asyncio
async def test_client_models_sorted_unique(monkeypatch: pytest.MonkeyPatch) -> None:
client = OpenAIClient(OpenAIConfig(access_key_or_token="sk", base_url="https://example/v1"))
class _Resp:
status_code = 200
@property
def content(self) -> bytes:
return b'{"object":"list","data":[{"id":"m2"},{"id":"m1"},{"id":"m2"},{"id":""}]}'
async def fake_get(path: str, **kwargs: object) -> _Resp:
assert path == "/models"
return _Resp()
monkeypatch.setattr(client.session, "get", fake_get)
ids = await client.models()
assert ids == ["m1", "m2"]
@pytest.mark.asyncio
async def test_client_models_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
client = OpenAIClient(OpenAIConfig(access_key_or_token="sk", base_url="https://example/v1"))
class _Resp:
status_code = 401
@property
def content(self) -> bytes:
return b'{"error":"nope"}'
def close(self) -> None:
return None
async def fake_get(path: str, **kwargs: object) -> _Resp:
del path, kwargs
return _Resp()
monkeypatch.setattr(client.session, "get", fake_get)
with pytest.raises(RuntimeError, match="HTTP 401"):
_ = await client.models()
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import msgspec
import pytest
from plyngent.lmproto.openai import (
OpenAIClient,
Response,
ResponseDeleted,
ResponseFunctionTool,
ResponseFunctionToolCall,
ResponsesCreateParam,
ResponseStreamEvent,
response_function_calls,
response_output_text,
)
from plyngent.lmproto.openai_compatible.config import OpenAIConfig
def _sample_response_body() -> bytes:
return (
b'{"id":"resp_1","object":"response","created_at":1,"model":"gpt-test",'
b'"status":"completed","output":['
b'{"id":"rs_1","type":"reasoning","content":[],"summary":[]},'
b'{"id":"msg_1","type":"message","role":"assistant","status":"completed",'
b'"content":[{"type":"output_text","text":"hello world","annotations":[]}]},'
b'{"type":"function_call","call_id":"call_1","name":"add",'
b'"arguments":"{\\"a\\":1}","status":"completed"}'
b"]}"
)
def test_response_decode_and_helpers() -> None:
parsed = msgspec.json.decode(_sample_response_body(), type=Response)
assert parsed.id == "resp_1"
assert parsed.object == "response"
assert response_output_text(parsed) == "hello world"
calls = response_function_calls(parsed)
assert len(calls) == 1
assert isinstance(calls[0], ResponseFunctionToolCall)
assert calls[0].name == "add"
assert calls[0].call_id == "call_1"
def test_create_param_encode_omits_defaults() -> None:
param = ResponsesCreateParam(
model="gpt-test",
input="hi",
tools=[ResponseFunctionTool(name="add", parameters={"type": "object"})],
)
raw = msgspec.json.encode(param)
data = msgspec.json.decode(raw)
assert data["model"] == "gpt-test"
assert data["input"] == "hi"
# omit_defaults: default stream=False is not encoded until client sets stream
assert "stream" not in data
assert data["tools"][0]["type"] == "function"
assert data["tools"][0]["name"] == "add"
def test_stream_event_decode() -> None:
raw = b'{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"hel"}'
event = msgspec.json.decode(raw, type=ResponseStreamEvent)
assert event.type == "response.output_text.delta"
assert event.delta == "hel"
@pytest.mark.asyncio
async def test_client_responses_create(monkeypatch: pytest.MonkeyPatch) -> None:
client = OpenAIClient(OpenAIConfig(access_key_or_token="sk", base_url="https://example/v1"))
class _Resp:
status_code = 200
@property
def content(self) -> bytes:
return _sample_response_body()
async def fake_post(path: str, **kwargs: object) -> _Resp:
assert path == "/responses"
assert kwargs.get("stream") is False
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
result = await client.responses(ResponsesCreateParam(model="gpt-test", input="hi"))
assert isinstance(result, Response)
assert response_output_text(result) == "hello world"
@pytest.mark.asyncio
async def test_client_responses_stream(monkeypatch: pytest.MonkeyPatch) -> None:
client = OpenAIClient(OpenAIConfig(access_key_or_token="sk", base_url="https://example/v1"))
class _Resp:
status_code = 200
async def iter_lines(self):
yield (
b'data: {"type":"response.output_text.delta","delta":"a",'
b'"item_id":"m","output_index":0,"content_index":0}'
)
yield b"data: [DONE]"
def close(self) -> None:
return None
async def fake_post(path: str, **kwargs: object) -> _Resp:
assert path == "/responses"
assert kwargs.get("stream") is True
return _Resp()
monkeypatch.setattr(client.session, "post", fake_post)
stream = await client.responses(
ResponsesCreateParam(model="gpt-test", input="hi"),
stream=True,
)
events = [event async for event in stream]
assert len(events) == 1
assert events[0].type == "response.output_text.delta"
assert events[0].delta == "a"
@pytest.mark.asyncio
async def test_client_get_delete_response(monkeypatch: pytest.MonkeyPatch) -> None:
client = OpenAIClient(OpenAIConfig(access_key_or_token="sk", base_url="https://example/v1"))
class _Get:
status_code = 200
@property
def content(self) -> bytes:
return _sample_response_body()
class _Del:
status_code = 200
@property
def content(self) -> bytes:
return b'{"id":"resp_1","object":"response.deleted","deleted":true}'
async def fake_get(path: str, **kwargs: object) -> _Get:
assert path == "/responses/resp_1"
return _Get()
async def fake_delete(path: str, **kwargs: object) -> _Del:
assert path == "/responses/resp_1"
return _Del()
monkeypatch.setattr(client.session, "get", fake_get)
monkeypatch.setattr(client.session, "delete", fake_delete)
got = await client.get_response("resp_1")
assert got.id == "resp_1"
deleted = await client.delete_response("resp_1")
assert isinstance(deleted, ResponseDeleted)
assert deleted.deleted is True
+21 -3
View File
@@ -9,7 +9,8 @@ from plyngent.config.models import (
OpenAIProvider,
)
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
from plyngent.lmproto.openai_compatible import OpenAIClient
from plyngent.lmproto.openai import OpenAIClient
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient
from plyngent.runtime import ProviderNotSupportedError, create_client, provider_to_openai_config
@@ -18,8 +19,24 @@ def test_openai_provider_defaults_base_url() -> None:
config = provider_to_openai_config(provider)
assert config.access_key_or_token == "sk-test"
assert config.base_url == "https://api.openai.com/v1"
from plyngent.agent.responses_client import ResponsesChatClient
client = create_client(provider)
assert isinstance(client, OpenAIClient)
assert isinstance(client, ResponsesChatClient)
assert hasattr(client, "chat_completions")
assert client._provider_tools == [{"type": "web_search"}]
def test_openai_provider_tools_passed_to_wrapper() -> None:
from plyngent.agent.responses_client import ResponsesChatClient
provider = OpenAIProvider(
access_key_or_token="sk-test",
provider_tools=[{"type": "web_search"}],
)
client = create_client(provider)
assert isinstance(client, ResponsesChatClient)
assert client._provider_tools == [{"type": "web_search"}]
def test_openai_compatible_requires_url() -> None:
@@ -34,7 +51,8 @@ def test_openai_compatible_client() -> None:
url="https://example.com/v1",
)
client = create_client(provider)
assert isinstance(client, OpenAIClient)
assert isinstance(client, OpenAICompatibleClient)
assert not isinstance(client, OpenAIClient)
assert provider_to_openai_config(provider).base_url == "https://example.com/v1"
+54 -16
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from plyngent.tools.process import close_pty, open_pty, read_pty, run_command, write_pty
@@ -26,9 +27,14 @@ def _field(text: str, name: str) -> str:
raise AssertionError(msg)
def _py(code: str) -> list[str]:
"""Cross-platform argv that runs a short Python snippet."""
return [sys.executable, "-c", code]
async def test_run_command_echo(workspace: object) -> None:
del workspace
out = await call_async(run_command, ["echo", "hi"])
out = await call_async(run_command, _py("print('hi')"))
assert "exit_code=0" in out
assert "hi" in out
@@ -44,13 +50,13 @@ async def test_run_command_cwd(workspace: object) -> None:
sub = workspace / "sub"
sub.mkdir()
_ = (sub / "f.txt").write_text("z", encoding="utf-8")
out = await call_async(run_command, ["ls"], cwd="sub")
out = await call_async(run_command, _py("import os; print('\\n'.join(os.listdir()))"), cwd="sub")
assert "f.txt" in out
async def test_run_command_timeout(workspace: object) -> None:
del workspace
out = await call_async(run_command, ["sleep", "5"], timeout_seconds=0.2)
out = await call_async(run_command, _py("import time; time.sleep(5)"), timeout_seconds=0.2)
assert "timed_out=true" in out
@@ -59,7 +65,7 @@ async def test_run_command_timeout_keeps_partial_output(workspace: object) -> No
# Print then sleep past the timeout so communicate has partial stdout after kill.
out = await call_async(
run_command,
["sh", "-c", "printf partial-out; sleep 5"],
_py("import sys, time; sys.stdout.write('partial-out'); sys.stdout.flush(); time.sleep(5)"),
timeout_seconds=0.3,
)
assert "timed_out=true" in out
@@ -68,7 +74,11 @@ async def test_run_command_timeout_keeps_partial_output(workspace: object) -> No
async def test_run_command_stdin(workspace: object) -> None:
del workspace
out = await call_async(run_command, ["cat"], stdin="hello-stdin\n")
out = await call_async(
run_command,
_py("import sys; print(sys.stdin.read(), end='')"),
stdin="hello-stdin\n",
)
assert "exit_code=0" in out
assert "hello-stdin" in out
@@ -77,7 +87,7 @@ async def test_run_command_env(workspace: object) -> None:
del workspace
out = await call_async(
run_command,
["sh", "-c", "printf '%s' \"$PLYNGENT_TEST_VAR\""],
_py("import os; print(os.environ.get('PLYNGENT_TEST_VAR', ''), end='')"),
env={"PLYNGENT_TEST_VAR": "from-env"},
)
assert "exit_code=0" in out
@@ -87,7 +97,7 @@ async def test_run_command_env(workspace: object) -> None:
async def test_pty_open_read_close(workspace: object) -> None:
del workspace
try:
opened = call_sync(open_pty, ["sleep", "30"])
opened = call_sync(open_pty, _py("import time; time.sleep(30)"))
assert "session_id=" in opened
session_id = _session_id(opened)
data = await call_async(read_pty, session_id, timeout=0.05)
@@ -109,7 +119,7 @@ def test_pty_denied(workspace: object) -> None:
async def test_pty_echo_output(workspace: object) -> None:
del workspace
try:
opened = call_sync(open_pty, ["/bin/echo", "hello-pty"])
opened = call_sync(open_pty, _py("print('hello-pty')"))
session_id = _session_id(opened)
text = await call_async(read_pty, session_id, timeout=2.0, until="hello-pty")
assert "hello-pty" in text
@@ -123,7 +133,19 @@ async def test_pty_echo_output(workspace: object) -> None:
async def test_write_pty(workspace: object) -> None:
del workspace
try:
opened = call_sync(open_pty, ["cat"])
# Line-oriented echo (portable stand-in for ``cat``).
opened = call_sync(
open_pty,
_py(
"import sys\n"
"while True:\n"
" line = sys.stdin.readline()\n"
" if not line:\n"
" break\n"
" sys.stdout.write(line)\n"
" sys.stdout.flush()\n"
),
)
session_id = _session_id(opened)
written = call_sync(write_pty, session_id, "pty-input\n")
assert "wrote=" in written
@@ -143,6 +165,10 @@ async def test_pty_exec_failure_surfaces(workspace: object) -> None:
del workspace
try:
opened = call_sync(open_pty, ["definitely-not-a-real-binary-xyz"])
# Windows ConPTY may fail at open; POSIX may open then fail on exec.
if "error:" in opened and "session_id=" not in opened:
assert "not found" in opened.lower() or "failed" in opened.lower()
return
session_id = _session_id(opened)
text = await call_async(read_pty, session_id, timeout=2.0)
# marker and/or dead process with 127
@@ -161,9 +187,9 @@ def test_pty_session_limit(workspace: object) -> None:
try:
PtyManager.set_limit_continue_hook(None)
PtyManager.configure(max_sessions=1)
first = call_sync(open_pty, ["sleep", "30"])
first = call_sync(open_pty, _py("import time; time.sleep(30)"))
assert "session_id=" in first
second = call_sync(open_pty, ["sleep", "30"])
second = call_sync(open_pty, _py("import time; time.sleep(30)"))
assert "limit" in second
finally:
PtyManager.close_all()
@@ -177,8 +203,8 @@ def test_pty_session_limit_continue(workspace: object) -> None:
try:
PtyManager.configure(max_sessions=1)
PtyManager.set_limit_continue_hook(lambda _reason: True)
first = call_sync(open_pty, ["sleep", "30"])
second = call_sync(open_pty, ["sleep", "30"])
first = call_sync(open_pty, _py("import time; time.sleep(30)"))
second = call_sync(open_pty, _py("import time; time.sleep(30)"))
assert "session_id=" in first
assert "session_id=" in second
assert PtyManager.max_sessions >= 2
@@ -194,7 +220,7 @@ async def test_pty_output_budget(workspace: object) -> None:
try:
PtyManager.set_limit_continue_hook(None)
PtyManager.configure(session_output_budget=64)
opened = call_sync(open_pty, ["sh", "-c", "yes x | head -c 1000"])
opened = call_sync(open_pty, _py("print('x' * 1000)"))
session_id = _session_id(opened)
# Drain until budget exhausted or process ends.
budget_hit = False
@@ -223,7 +249,7 @@ async def test_pty_output_budget_is_per_session(workspace: object) -> None:
PtyManager.configure(session_output_budget=1024)
class_budget = PtyManager.session_output_budget
PtyManager.set_limit_continue_hook(lambda _reason: True)
opened = call_sync(open_pty, ["sh", "-c", "yes x | head -c 200"])
opened = call_sync(open_pty, _py("print('x' * 200)"))
session_id = _session_id(opened)
session = PtyManager.get(session_id)
assert session is not None
@@ -247,11 +273,23 @@ def test_pty_master_not_inheritable(workspace: object) -> None:
del workspace
import os
if sys.platform == "win32":
import pytest
pytest.skip("master FD inheritance is POSIX-only")
try:
opened = call_sync(open_pty, ["sleep", "5"])
opened = call_sync(open_pty, _py("import time; time.sleep(5)"))
session_id = _session_id(opened)
session = PtyManager.get(session_id)
assert session is not None
assert session.master_fd is not None
assert os.get_inheritable(session.master_fd) is False
finally:
PtyManager.close_all()
def test_pty_backend_available() -> None:
from plyngent.tools.process.pty_backend import pty_available
assert pty_available() is True
+25
View File
@@ -0,0 +1,25 @@
from collections.abc import Sequence
class PtyProcess:
exitstatus: int | None
@classmethod
def spawn(
cls,
argv: str | Sequence[str],
cwd: str | None = None,
env: dict[str, str] | None = None,
dimensions: tuple[int, int] = (24, 80),
backend: int | None = None,
) -> PtyProcess: ...
def isalive(self) -> bool: ...
def read(self, size: int = 1024) -> str: ...
def write(self, s: str) -> int: ...
def terminate(self, force: bool = False) -> bool: ...
def kill(self, sig: int | None = None) -> None: ...
def close(self, force: bool = False) -> None: ...
def wait(self) -> int | None: ...
def fileno(self) -> int: ...
def isatty(self) -> bool: ...
def setwinsize(self, rows: int, cols: int) -> None: ...
def getwinsize(self) -> tuple[int, int]: ...