mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
18 Commits
4ea3362058
...
6a1160f1e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a1160f1e3 | |||
| 3f67f5634e | |||
| 5d2f0c5539 | |||
| d267a5f869 | |||
| 074a3313ce | |||
| 84a83e0485 | |||
| 16992be743 | |||
| 1f75cde3dd | |||
| 5dd102a016 | |||
| 38edc09854 | |||
| 3e20b1559c | |||
| 61582ffd7d | |||
| fbba2c2a14 | |||
| e1547a242c | |||
| c466d09173 | |||
| 611147b0a5 | |||
| 6bd80cb915 | |||
| e1ea43cb8f |
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Executable
+222
@@ -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())
|
||||
Executable
+5
@@ -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" "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"}:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]: ...
|
||||
Reference in New Issue
Block a user