mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 00:47:54 +08:00
Compare commits
18 Commits
4ea3362058
..
v0.1.0
| 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_compatible/ ← Base: model, config, client
|
||||||
|
lmproto/openai/ ← OpenAI platform: Responses + chat (extends base)
|
||||||
lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields
|
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/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_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`.
|
- **DeepSeek** — `DeepseekOpenAIClient`; models add `reasoning_content`, `prefix`, `ThinkingOptions`. Config default model ids: `deepseek-v4-flash`, `deepseek-v4-pro`.
|
||||||
|
|
||||||
### Config (`config/`)
|
### Config (`config/`)
|
||||||
@@ -44,7 +47,7 @@ TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]
|
|||||||
|
|
||||||
### Runtime (`runtime/`)
|
### 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/`)
|
### Memory (`memory/`)
|
||||||
|
|
||||||
@@ -52,7 +55,9 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
|||||||
|
|
||||||
### Agent (`agent/`)
|
### 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.
|
- **`@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`.
|
- **`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).
|
- **`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.
|
- **`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`.
|
- **`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`).
|
- **`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: structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)`; session limit/idle TTL/output budget; close SIGTERM→SIGKILL.
|
- 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.
|
- 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`.
|
- 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.
|
- **`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.
|
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
|
## Install
|
||||||
|
|
||||||
```bash
|
### Quick try (`uvx`)
|
||||||
# from a clone
|
|
||||||
pdm install
|
|
||||||
pdm run plyngent --help
|
|
||||||
|
|
||||||
# or editable install into your environment
|
No permanent install — runs the published package in a temporary environment:
|
||||||
pdm install
|
|
||||||
# entry point: plyngent
|
```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:
|
Dev checks:
|
||||||
@@ -25,6 +71,30 @@ pdm run ruff format .
|
|||||||
pdm run pytest
|
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
|
## Configure
|
||||||
|
|
||||||
Default config path (platformdirs):
|
Default config path (platformdirs):
|
||||||
@@ -58,7 +128,7 @@ confirm_destructive = true
|
|||||||
max_context_tokens = 200000
|
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).
|
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) |
|
| `/new` `/resume` `/rename` `/delete` | Session lifecycle (`/delete` confirms) |
|
||||||
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
| `/export [md\|json] [path]` | Transcript from DB (no secrets) |
|
||||||
| `/compact` | Soft-compact + model summary into a **new** session |
|
| `/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) |
|
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
|
||||||
| `/provider` `/model` | Switch without restarting |
|
| `/provider` `/model` | Switch without restarting |
|
||||||
|
| `/models` | List config + remote `GET /models` (`--refresh` bypasses cache) |
|
||||||
| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
|
| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
|
||||||
| `/quit` | Leave the REPL |
|
| `/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)
|
## 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:
|
Safety defaults:
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,21 @@ access_key_or_token = "sk-replace-me"
|
|||||||
[providers.openai_compat.models]
|
[providers.openai_compat.models]
|
||||||
"gpt-4o-mini" = { text = true, cost_factor = 1.0 }
|
"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]
|
# [providers.openai]
|
||||||
# preset = "openai"
|
# preset = "openai"
|
||||||
# access_key_or_token = "sk-replace-me"
|
# access_key_or_token = "sk-replace-me"
|
||||||
# url = "https://api.openai.com/v1"
|
# 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]
|
# [providers.openai.models]
|
||||||
# "gpt-4o-mini" = { text = true }
|
# "gpt-4o-mini" = { text = true }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
groups = ["default", "dev"]
|
groups = ["default", "dev"]
|
||||||
strategy = ["inherit_metadata"]
|
strategy = ["inherit_metadata"]
|
||||||
lock_version = "4.5.0"
|
lock_version = "4.5.0"
|
||||||
content_hash = "sha256:cd5131c309485626148d1933a2a557a9cb6a41038b980bc26a453def4c38032c"
|
content_hash = "sha256:b849e1647ce0ec47b61889ae0e66863acce020cd1f2e3deaeb9e2e92becf9da5"
|
||||||
|
|
||||||
[[metadata.targets]]
|
[[metadata.targets]]
|
||||||
requires_python = "~=3.14"
|
requires_python = "~=3.14"
|
||||||
@@ -52,45 +52,39 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.7"
|
version = "3.4.9"
|
||||||
requires_python = ">=3.7"
|
requires_python = ">=3.7"
|
||||||
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
files = [
|
files = [
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"},
|
||||||
{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.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"},
|
||||||
{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.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"},
|
||||||
{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.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"},
|
||||||
{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.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.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"},
|
||||||
{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.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
|
{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.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
|
{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.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
|
{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.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
|
{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.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"},
|
||||||
{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.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"},
|
||||||
{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.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"},
|
||||||
{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.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
|
{file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
|
{file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"},
|
||||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
|
{file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"},
|
||||||
{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"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -231,6 +225,31 @@ files = [
|
|||||||
{file = "jh2-5.0.13.tar.gz", hash = "sha256:f8c78cffb3a35c4410513c3eb7989de36028c84277c04f07c97909dd94c23a75"},
|
{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]]
|
[[package]]
|
||||||
name = "msgspec"
|
name = "msgspec"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -259,7 +278,7 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "niquests"
|
name = "niquests"
|
||||||
version = "3.20.0"
|
version = "3.20.1"
|
||||||
requires_python = ">=3.7"
|
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."
|
summary = "Niquests is a simple, yet elegant, HTTP library. It is a drop-in replacement for Requests, which is under feature freeze."
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
@@ -269,8 +288,8 @@ dependencies = [
|
|||||||
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
|
"wassima<3,>=1.0.1; sys_platform != \"emscripten\"",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "niquests-3.20.0-py3-none-any.whl", hash = "sha256:86281a62314d7395f049d748480589e273dc1f429953afbac377fff142860429"},
|
{file = "niquests-3.20.1-py3-none-any.whl", hash = "sha256:71062880739c94d67aead1f8b7972baad3236b97a55a490493729789d1639fc4"},
|
||||||
{file = "niquests-3.20.0.tar.gz", hash = "sha256:dba85ce23ac5052f0a1e1c1cf1c017511a993f906686e8b6c84e0b54818fd1dc"},
|
{file = "niquests-3.20.1.tar.gz", hash = "sha256:55951812cf997963bfef8e4f797ff0460ca571e53ec184039b010e12152c0e41"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -332,7 +351,7 @@ name = "pygments"
|
|||||||
version = "2.20.0"
|
version = "2.20.0"
|
||||||
requires_python = ">=3.9"
|
requires_python = ">=3.9"
|
||||||
summary = "Pygments is a syntax highlighting package written in Python."
|
summary = "Pygments is a syntax highlighting package written in Python."
|
||||||
groups = ["dev"]
|
groups = ["default", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
|
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
|
||||||
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
|
{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"},
|
{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]]
|
[[package]]
|
||||||
name = "qh3"
|
name = "qh3"
|
||||||
version = "1.9.2"
|
version = "1.9.4"
|
||||||
requires_python = ">=3.7"
|
requires_python = ">=3.7"
|
||||||
summary = "A lightway and fast implementation of QUIC and HTTP/3"
|
summary = "A lightway and fast implementation of QUIC and HTTP/3"
|
||||||
groups = ["default"]
|
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\""
|
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 = [
|
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.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:100368855b0e103774438aaf8b38c711fb51b79f18e3b9dc873ea573648bcc3d"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3beebd98b08bd2fe951ae83b6e52dee3821158ab21ba05f15fbdb401bd6e19f9"},
|
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9d45e544e0a556380bd2a92af180ee98d32bca6cf622f28350ebc000611bdf"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6afe20084235ff3c20256c952059277b5a0f5144d63d1892d1dfb5d1b635faef"},
|
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43f80d662078c26563a1aac32a4df22649473d996bf2422e073c8aa55b1c837c"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c8f2fdf124acef06306ee50421ee80d37dbc7dfd41ddcec2bb63f3e2508bd74"},
|
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:551f60d487d849d6f2313dda94e54ebb16b1a29acb93676540ae790a56308c24"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:adcb00af8c4fddbb266ae4155ec5932dcb0a27dc14d7f7a27fad6a6dce9efd5e"},
|
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3755d777ff35561bc719f571ebc4b91aaa8a945c210e50b2576bf2cc69cb511f"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcbf94d8ab4223873f2fc2f7a0992ee0dd557f0ab5bce1ca12314e60a1d5cd39"},
|
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04b64bb18e10d8f482bea1ef43e5da3448e2d5ba78bb0c735da5b9e02d7dfb0e"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13b458a440f91d5336dd65f4a624b7eeac0ce1b2a8d02e250df32470606c6775"},
|
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b47597daa4ae9195f81a35822217a395d3277f01a393d9c2bb3796a6b458b22c"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7b0429d9c5d892633ee822a428c840201a2ef53a16cec4d4aeb1ae08146cae9"},
|
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9bb9012bc02c44da6af1b931b6082588519685fad8a1f866c341f749394f53f"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:d4ab482b9f12abb5fc6fc67a214431037d094b38673efd6d0df5d64338dd5351"},
|
{file = "qh3-1.9.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9c7f3781ec022dd958b5eb0e5cb2e2a54e207a3d8837fb7290bc239912648fd7"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ab254e2289bfc949b0177aa7739ac963f54355716aee39966120ca71d7315aeb"},
|
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:633a24ab2db76d64881876c9d0f1b226a47d892bd7d78e61ea2c788ab59570af"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9a6aa7146a62c88fbc8adfa6dcae435670a8487e5f3bb00f92a29060cd630102"},
|
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:f2ff36b84a4ab9b68806af1f6985fa3b47e29695effe766a3e70c21efac8157c"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2710e359d9320014a33149853cbc8c4bd9ca83181bb7a896c7704a1e429c27c4"},
|
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2100697d736dbdfd1d3f0266c9fcadea6230b66ed59e3e7f8c9a9d1a6eec0c65"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:4d136fa78171ffc094e06c25361fe00482accdb406a442ce93e8121314880659"},
|
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:9ce179efaf97f927f01fe158619f5624d8d874c7161b7c497f06897cdde210d5"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a48cc6b4d21ef85071ef2f20ba27d7412827ff8385baa19d14b0c58e44fdbc57"},
|
{file = "qh3-1.9.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:476ee4fcdbd3454eb0eb4dee5678294f06c08ca8e8b83160e2d0ae3c4ae34603"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-win32.whl", hash = "sha256:c4dc40c1ac0f31bfc0dadeaf02578460c7456498f549bb6ae742883f90286243"},
|
{file = "qh3-1.9.4-cp314-cp314t-win32.whl", hash = "sha256:fe9df8136bf358d45a2757287523abaeb208fcbe2c1e34664562eb2d8f64dbdb"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:19893cfe25a749c363e81415dcdb3b027707e71a09b0e1d4f1e3098d7e5975d3"},
|
{file = "qh3-1.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:1fbdf53feb1732faf6c1cb34f2e1cbe4b543054db7b4d19d8752f1162e9370be"},
|
||||||
{file = "qh3-1.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:75b587b26a8cdeb1c715e5d2ec08d4987be8e02bb76a6b24a4075293ade0bd3d"},
|
{file = "qh3-1.9.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e03ee1e2157a31c69415c3dbfbfe64e02469499ca46e968d13d8a7b289bf520a"},
|
||||||
{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.4-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:26f7c8ed1a7ac0e25b654f030a4f472830c80890cc284dc40a31c17ecc21fbf0"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e632d420d88e30e4e3fc9f5457c362e964e93e91f9c90d148b68b0d4b0dc06"},
|
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57b3dfe5dd2da2084adea63dba0bf587a4096ebf9207339187c90139d1f460d"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5260ff1c42f7221fb7e92bc9c55a009341c2851f2dbb28475ddcf0a32c2226a"},
|
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fa1a3debb5199da3c7f45b0ca6efa55174dfd6229370e774099073143ebe9e9"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:304e81b4cf3f97610a1aceaa1aec90a97f0c700071efcc9d6a3b86da0b57539d"},
|
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dc9ff40d3a06f27b47d93417666f03ba6fd6b9585d9711ebbddf84fa4b7c50f"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:aa20581d3daf8f80b62f42a10c1f69c12f3b6310f141a2e2f4259de588e570fe"},
|
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:010b132c33719af3df4c1d6f569e52a2a20046c55a31c5532e741019c8c53868"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd3a3d7d737644a4d24117613e89220cd5223cb1e0c8281a5a65a97ebaf1efea"},
|
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:124045b0c04c05afc038a239d7717019b4c4b9d0cb967040b4331f4d588eff7d"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82edac5bc2eea0881baa142d93e80469347875909acc19ce5e4236b50c465f8e"},
|
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a94a1726bf46a451a0295d68fafde28794e83ab6fda7457cbd9309c38151de9"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b18b9d2f892bb95f7f3d3428bb4166f974c9a2e26fcf91b33dda9b4285e7928"},
|
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b361e627b6a529ecb231c1d5f3f2abd6d1952b0d7cc02c7718d9657d55dae37"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:06f2a10a01b71137fef8bb7b4115f1016d6e4a7f056ce41517ff157e808bf444"},
|
{file = "qh3-1.9.4-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:43b6cfb2240836e3e54bbe61ad1b8fa5716516ff5359131a2421c55676a24f26"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e15519dc326315c52ab708028bf72aa9162eb3c1bd0ba130dc6d371b000639d2"},
|
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e6db3def610e9c2d2f3602d3e9c8b2156273225f6eedf74a4749d3bc21af8167"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:7e04bf8bbbfb8a9f363fd38834aa9c52fcc0164182c72ea10cf30a3e2df066c9"},
|
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:22d5879dc93d2c7ee14cc5933b12c7780d9c7705dff4553becea152fcb767d99"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:e3b0b0d17268d57b808a658db7fd7c5e62561ad6d462949d8466c92b22551067"},
|
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:2c2b2005fe6c605d61cac05ec2b3c019cc546184fa43444f3d1e3d22ed020ab2"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:efe5ad0a9df71a9a5881615b0c0888767ad3a7cab05e54ba98db52d89d0b796a"},
|
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:e991b0f20b172fea466d0ffd499a85bf2f26decdeeda537275c72b5f7b26ccfe"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:742dd7c5e2207db27d24036ca599cad056bd5e58a5ce1b43f0c424df149e17d3"},
|
{file = "qh3-1.9.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c13775ed27a88b297f6a1aa05ffb286879453f99e1e53e0af2b22e7ed8f92ebf"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-win32.whl", hash = "sha256:8d1f185a76578929b0de3dcef135c0cc21d4bff0ef00bd75127d244ba3426125"},
|
{file = "qh3-1.9.4-cp37-abi3-win32.whl", hash = "sha256:952abe1be90c0114993886792a8a5ec2211f8391df41227f5b7a5a02ff71bc26"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-win_amd64.whl", hash = "sha256:643e8aa05519dddaf20159d1c13c15473de7649622a1c438968ce0f0da973951"},
|
{file = "qh3-1.9.4-cp37-abi3-win_amd64.whl", hash = "sha256:5543cb40a48d401f3fa4ec89398e3a213634f195716a9d06d70417c05edbbb32"},
|
||||||
{file = "qh3-1.9.2-cp37-abi3-win_arm64.whl", hash = "sha256:87bace13780b3c2238db1dd31deb10152203d5b360ca6515c78b853796245606"},
|
{file = "qh3-1.9.4-cp37-abi3-win_arm64.whl", hash = "sha256:d0bd73b3e73f4f80d2430d410b532222ecf57e9d4fe8600b7683c775cbff9479"},
|
||||||
{file = "qh3-1.9.2.tar.gz", hash = "sha256:c6c92f63c2ec292256b5a5ed9345c42344bdaca2e55ec795623987a563aea19c"},
|
{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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.20"
|
version = "0.15.21"
|
||||||
requires_python = ">=3.7"
|
requires_python = ">=3.7"
|
||||||
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||||
groups = ["dev"]
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078"},
|
{file = "ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d"},
|
||||||
{file = "ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b"},
|
{file = "ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd"},
|
||||||
{file = "ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632"},
|
{file = "ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646"},
|
||||||
{file = "ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd"},
|
{file = "ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be"},
|
||||||
{file = "ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b"},
|
{file = "ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd"},
|
||||||
{file = "ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267"},
|
{file = "ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41"},
|
||||||
{file = "ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c"},
|
{file = "ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86"},
|
||||||
{file = "ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae"},
|
{file = "ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67"},
|
||||||
{file = "ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b"},
|
{file = "ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8"},
|
||||||
{file = "ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487"},
|
{file = "ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075"},
|
||||||
{file = "ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3"},
|
{file = "ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341"},
|
||||||
{file = "ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053"},
|
{file = "ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d"},
|
||||||
{file = "ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4"},
|
{file = "ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233"},
|
||||||
{file = "ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460"},
|
{file = "ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f"},
|
||||||
{file = "ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21"},
|
{file = "ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd"},
|
||||||
{file = "ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415"},
|
{file = "ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3"},
|
||||||
{file = "ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca"},
|
{file = "ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8"},
|
||||||
{file = "ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566"},
|
{file = "ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -500,7 +549,7 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3-future"
|
name = "urllib3-future"
|
||||||
version = "2.22.900"
|
version = "2.22.901"
|
||||||
requires_python = ">=3.7"
|
requires_python = ">=3.7"
|
||||||
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
|
summary = "urllib3.future is a powerful HTTP 1.1, 2, and 3 client with both sync and async interfaces"
|
||||||
groups = ["default"]
|
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\"",
|
"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 = [
|
files = [
|
||||||
{file = "urllib3_future-2.22.900-py3-none-any.whl", hash = "sha256:803976fe865f03d61d5ddc17dbdbac2bacc08202aad303f282cc4af319ddbfd2"},
|
{file = "urllib3_future-2.22.901-py3-none-any.whl", hash = "sha256:dc2d4572ba612be69e3f6db87d1f469b7d08f107e80fd8749081d7e6a5a2fe10"},
|
||||||
{file = "urllib3_future-2.22.900.tar.gz", hash = "sha256:3161900c00cf41db90dd3d938c1d14960faab475dd6ca0bf5ecd06112327c714"},
|
{file = "urllib3_future-2.22.901.tar.gz", hash = "sha256:fbece0ff51299a213c926897ffbed61d820ab5ef2ba81cece361c049fe2f51a9"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wassima"
|
name = "wassima"
|
||||||
version = "2.1.1"
|
version = "2.1.2"
|
||||||
requires_python = ">=3.7"
|
requires_python = ">=3.7"
|
||||||
summary = "Access your OS root certificates with utmost ease"
|
summary = "Access your OS root certificates with utmost ease"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
marker = "sys_platform != \"emscripten\""
|
marker = "sys_platform != \"emscripten\""
|
||||||
files = [
|
files = [
|
||||||
{file = "wassima-2.1.1-py3-none-any.whl", hash = "sha256:ab0f12c091ff697f111eea5f925aafd83736909a1c9c764a8bbf874b2e4d4a42"},
|
{file = "wassima-2.1.2-py3-none-any.whl", hash = "sha256:9a5779e8ade39960973c6cc17e095cc998d6345ab3dbdd1ec2d7fde8c356fb82"},
|
||||||
{file = "wassima-2.1.1.tar.gz", hash = "sha256:9c6ad4aa3cfbe91fd75f9eae315ba563bbc7d9d2479aef0c288fa7f1ca3b0c53"},
|
{file = "wassima-2.1.2.tar.gz", hash = "sha256:f74b5441151728c54118ece6d747cfe92b2c595a3d062a1955f42a2bc894cd10"},
|
||||||
]
|
]
|
||||||
|
|||||||
+13
-1
@@ -14,7 +14,9 @@ dependencies = [
|
|||||||
"sqlalchemy>=2.0.51",
|
"sqlalchemy>=2.0.51",
|
||||||
"aiosqlite>=0.22.1",
|
"aiosqlite>=0.22.1",
|
||||||
"click>=8.4.2",
|
"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"
|
requires-python = ">=3.14,<4"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -38,6 +40,8 @@ build-backend = "pdm.backend"
|
|||||||
[tool.basedpyright]
|
[tool.basedpyright]
|
||||||
typeCheckingMode = "recommended"
|
typeCheckingMode = "recommended"
|
||||||
strictGenericNarrowing = true
|
strictGenericNarrowing = true
|
||||||
|
# winpty is Windows-only (pywinpty); stubs live under typings/ (pyright default stubPath).
|
||||||
|
reportMissingModuleSource = "hint"
|
||||||
reportAny = "hint"
|
reportAny = "hint"
|
||||||
reportExplicitAny = "hint"
|
reportExplicitAny = "hint"
|
||||||
reportImplicitStringConcatenation = "hint"
|
reportImplicitStringConcatenation = "hint"
|
||||||
@@ -60,6 +64,10 @@ reportUnusedParameter = "none"
|
|||||||
reportMissingImports = "none"
|
reportMissingImports = "none"
|
||||||
reportMissingTypeStubs = "none"
|
reportMissingTypeStubs = "none"
|
||||||
|
|
||||||
|
[[tool.basedpyright.executionEnvironments]]
|
||||||
|
root = "scripts"
|
||||||
|
reportUnusedCallResult = "none"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
@@ -163,3 +171,7 @@ allowed-confusables = [
|
|||||||
"TRY003", # long exception messages in tests
|
"TRY003", # long exception messages in tests
|
||||||
"FBT", # boolean positional args in helpers
|
"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 .events import UsageEvent as UsageEvent
|
||||||
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
|
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
|
||||||
from .loop import run_chat_loop as run_chat_loop
|
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 DangerClassifier as DangerClassifier
|
||||||
from .tools import ToolConfirmHook as ToolConfirmHook
|
from .tools import ToolConfirmHook as ToolConfirmHook
|
||||||
from .tools import ToolDefinition as ToolDefinition
|
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:
|
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:
|
if usage is None or usage is UNSET:
|
||||||
return None
|
return None
|
||||||
if not isinstance(usage, dict):
|
if not isinstance(usage, dict):
|
||||||
return None
|
return None
|
||||||
raw = cast("dict[str, object]", usage)
|
raw = cast("dict[str, object]", usage)
|
||||||
prompt = _as_nonneg_int(raw.get("prompt_tokens"))
|
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"))
|
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"))
|
total = _as_nonneg_int(raw.get("total_tokens"))
|
||||||
if total == 0 and (prompt or completion):
|
if total == 0 and (prompt or completion):
|
||||||
total = prompt + 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:
|
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:
|
if not bad:
|
||||||
return
|
return
|
||||||
names = ", ".join(sorted(bad.keys()))
|
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)
|
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:
|
def _database_config(store: ConfigStore, *, quiet: bool = False) -> DatabaseConfig:
|
||||||
raw = dict(store.database)
|
raw = dict(store.database)
|
||||||
# Prefer a durable file DB so sessions survive CLI restarts.
|
# 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
|
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,
|
config_path: Path | None,
|
||||||
provider_name: str | None,
|
provider_name: str | None,
|
||||||
@@ -187,14 +199,19 @@ async def _run_chat( # noqa: C901 — chat orchestration
|
|||||||
configure_prompting(backend=NonInteractiveBackend())
|
configure_prompting(backend=NonInteractiveBackend())
|
||||||
|
|
||||||
store = _load_config(config_path)
|
store = _load_config(config_path)
|
||||||
if store.bad_providers and not quiet:
|
if not quiet:
|
||||||
_warn_bad_providers(store.bad_providers)
|
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)
|
_setup_workspace_and_hooks(store, workspace, interactive=interactive)
|
||||||
confirm_destructive: bool | None = False if yes else None
|
confirm_destructive: bool | None = False if yes else None
|
||||||
|
|
||||||
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
|
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
|
||||||
try:
|
try:
|
||||||
|
from plyngent.cli.provider_recovery import ensure_provider_ready
|
||||||
|
|
||||||
# Prefer session-remembered LLM when resuming (unless flags override).
|
# Prefer session-remembered LLM when resuming (unless flags override).
|
||||||
preferred_provider = provider_name
|
preferred_provider = provider_name
|
||||||
preferred_model = model
|
preferred_model = model
|
||||||
@@ -211,10 +228,17 @@ async def _run_chat( # noqa: C901 — chat orchestration
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
pname, provider = select_provider(
|
pname, provider = select_provider(
|
||||||
store.providers,
|
store.selectable_providers(),
|
||||||
preferred=preferred_provider,
|
preferred=preferred_provider,
|
||||||
interactive=interactive,
|
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)
|
model_id = select_model(provider, preferred=preferred_model, interactive=interactive)
|
||||||
_ = create_client(provider)
|
_ = create_client(provider)
|
||||||
except ProviderNotSupportedError as exc:
|
except ProviderNotSupportedError as exc:
|
||||||
@@ -414,12 +438,15 @@ def chat_cmd(
|
|||||||
def providers_cmd(config_path: Path | None) -> None:
|
def providers_cmd(config_path: Path | None) -> None:
|
||||||
"""List configured providers."""
|
"""List configured providers."""
|
||||||
store = _load_config(config_path)
|
store = _load_config(config_path)
|
||||||
if not store.providers:
|
if not store.providers and not store.recoverable_providers:
|
||||||
click.echo("(no providers)")
|
click.echo("(no providers)")
|
||||||
for name, provider in sorted(store.providers.items()):
|
for name, provider in sorted(store.providers.items()):
|
||||||
tag = type(provider).__struct_config__.tag
|
tag = type(provider).__struct_config__.tag
|
||||||
models = ", ".join(sorted(provider.models.keys())) or "(none listed)"
|
models = ", ".join(sorted(provider.models.keys())) or "(none listed)"
|
||||||
click.echo(f"{name}\tpreset={tag}\tmodels={models}")
|
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:
|
if store.bad_providers:
|
||||||
_warn_bad_providers(store.bad_providers)
|
_warn_bad_providers(store.bad_providers)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -25,8 +28,9 @@ if TYPE_CHECKING:
|
|||||||
_TOOL_RESULT_PREVIEW = 120
|
_TOOL_RESULT_PREVIEW = 120
|
||||||
_TOOL_ARGS_PREVIEW = 80
|
_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)
|
_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
|
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()
|
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:
|
def _preview(text: str, limit: int) -> str:
|
||||||
if len(text) <= limit:
|
if len(text) <= limit:
|
||||||
return text
|
return text
|
||||||
@@ -47,23 +70,64 @@ def _preview(text: str, limit: int) -> str:
|
|||||||
def _echo_stream(text: str) -> None:
|
def _echo_stream(text: str) -> None:
|
||||||
"""Write without newline and flush so assistant text appears token-by-token."""
|
"""Write without newline and flush so assistant text appears token-by-token."""
|
||||||
click.echo(text, nl=False)
|
click.echo(text, nl=False)
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
import sys
|
|
||||||
|
|
||||||
_ = sys.stdout.flush()
|
_ = 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
|
async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||||
events: AsyncIterator[AgentEvent],
|
events: AsyncIterator[AgentEvent],
|
||||||
*,
|
*,
|
||||||
verbose: bool | None = None,
|
verbose: bool | None = None,
|
||||||
|
markdown: bool | None = 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
|
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_reasoning = False
|
||||||
printed_text = 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:
|
async for event in events:
|
||||||
if isinstance(event, ReasoningDeltaEvent):
|
if isinstance(event, ReasoningDeltaEvent):
|
||||||
if not printed_reasoning:
|
if not printed_reasoning:
|
||||||
@@ -78,8 +142,11 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
|||||||
click.echo()
|
click.echo()
|
||||||
click.secho("assistant: ", fg="cyan", nl=False)
|
click.secho("assistant: ", fg="cyan", nl=False)
|
||||||
printed_text = True
|
printed_text = True
|
||||||
|
assistant_buf.append(event.content)
|
||||||
_echo_stream(event.content)
|
_echo_stream(event.content)
|
||||||
elif isinstance(event, ToolCallEvent):
|
elif isinstance(event, ToolCallEvent):
|
||||||
|
if printed_text:
|
||||||
|
interrupted_by_other = True
|
||||||
call = event.tool_call
|
call = event.tool_call
|
||||||
if isinstance(call, AssistantFunctionToolCall):
|
if isinstance(call, AssistantFunctionToolCall):
|
||||||
args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW)
|
args = _preview(call.function.arguments, _TOOL_ARGS_PREVIEW)
|
||||||
@@ -87,15 +154,18 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
|||||||
else:
|
else:
|
||||||
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
|
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
|
||||||
elif isinstance(event, ToolResultEvent):
|
elif isinstance(event, ToolResultEvent):
|
||||||
|
if printed_text:
|
||||||
|
interrupted_by_other = True
|
||||||
content = event.message.content
|
content = event.message.content
|
||||||
if show_full:
|
if show_full:
|
||||||
click.secho(f"[tool ok]\n{content}", fg="magenta")
|
click.secho(f"[tool ok]\n{content}", fg="magenta")
|
||||||
else:
|
else:
|
||||||
preview = _preview(content, _TOOL_RESULT_PREVIEW)
|
preview = _preview(content, _TOOL_RESULT_PREVIEW)
|
||||||
# Single-line summary; full result stays in the message history for the model.
|
|
||||||
one_line = preview.replace("\n", " ")
|
one_line = preview.replace("\n", " ")
|
||||||
click.secho(f"[tool ok] {one_line}", fg="magenta")
|
click.secho(f"[tool ok] {one_line}", fg="magenta")
|
||||||
elif isinstance(event, ErrorEvent):
|
elif isinstance(event, ErrorEvent):
|
||||||
|
if printed_text:
|
||||||
|
interrupted_by_other = True
|
||||||
suffix = ""
|
suffix = ""
|
||||||
if event.source:
|
if event.source:
|
||||||
suffix += f" source={event.source}"
|
suffix += f" source={event.source}"
|
||||||
@@ -103,11 +173,15 @@ async def render_events( # noqa: C901, PLR0912, PLR0915
|
|||||||
suffix += " (fatal)"
|
suffix += " (fatal)"
|
||||||
click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red")
|
click.secho(f"\n[error]{suffix} {event.message}", fg="bright_red")
|
||||||
elif isinstance(event, CancelledEvent):
|
elif isinstance(event, CancelledEvent):
|
||||||
|
if printed_text:
|
||||||
|
interrupted_by_other = True
|
||||||
if event.reason:
|
if event.reason:
|
||||||
click.secho(f"\n[cancelled] {event.reason}", fg="yellow")
|
click.secho(f"\n[cancelled] {event.reason}", fg="yellow")
|
||||||
else:
|
else:
|
||||||
click.secho("\n[cancelled]", fg="yellow")
|
click.secho("\n[cancelled]", fg="yellow")
|
||||||
elif isinstance(event, MaxRoundsEvent):
|
elif isinstance(event, MaxRoundsEvent):
|
||||||
|
if printed_text:
|
||||||
|
interrupted_by_other = True
|
||||||
if event.continued:
|
if event.continued:
|
||||||
click.secho(
|
click.secho(
|
||||||
f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]",
|
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:
|
else:
|
||||||
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
|
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
|
||||||
elif isinstance(event, UsageEvent):
|
elif isinstance(event, UsageEvent):
|
||||||
# Printed at end of turn as a summary; individual round usage is quiet.
|
|
||||||
_ = event
|
_ = event
|
||||||
else:
|
else:
|
||||||
# AssistantMessageEvent — text already shown via TextDeltaEvent.
|
# AssistantMessageEvent — text already shown via TextDeltaEvent.
|
||||||
_ = event
|
_ = 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:
|
if printed_text or printed_reasoning:
|
||||||
click.echo()
|
click.echo()
|
||||||
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
|
import click
|
||||||
|
|
||||||
|
from plyngent.cli.models_source import config_model_ids, model_choices_for_provider
|
||||||
from plyngent.prompting import ChoiceOption, choose
|
from plyngent.prompting import ChoiceOption, choose
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
from plyngent.config.models import Provider
|
from plyngent.config.models import Provider
|
||||||
|
|
||||||
@@ -56,14 +57,20 @@ def select_model(
|
|||||||
*,
|
*,
|
||||||
preferred: str | None = None,
|
preferred: str | None = None,
|
||||||
interactive: bool = True,
|
interactive: bool = True,
|
||||||
|
choices: Sequence[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Pick a model id from provider.models or free-form prompt (readline + Tab)."""
|
"""Pick a model id from config/remote choices or free-form prompt.
|
||||||
model_names = sorted(provider.models.keys())
|
|
||||||
|
*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 preferred is not None:
|
||||||
if model_names and preferred not in provider.models:
|
token = preferred.strip()
|
||||||
msg = f"unknown model {preferred!r}; available: {', '.join(model_names)}"
|
if not token:
|
||||||
|
msg = "model id must not be empty"
|
||||||
raise click.ClickException(msg)
|
raise click.ClickException(msg)
|
||||||
return preferred
|
return token
|
||||||
|
|
||||||
if len(model_names) == 1:
|
if len(model_names) == 1:
|
||||||
model = model_names[0]
|
model = model_names[0]
|
||||||
@@ -81,9 +88,14 @@ def select_model(
|
|||||||
return choose(
|
return choose(
|
||||||
"Select model",
|
"Select model",
|
||||||
model_names,
|
model_names,
|
||||||
allow_custom=False,
|
allow_custom=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
from plyngent.prompting import ask
|
from plyngent.prompting import ask
|
||||||
|
|
||||||
return ask("Model id (not listed in config)")
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
from typing import TYPE_CHECKING, Any, cast, override
|
from typing import TYPE_CHECKING, Any, cast, override
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ import click
|
|||||||
from click.shell_completion import CompletionItem
|
from click.shell_completion import CompletionItem
|
||||||
from msgspec import UNSET
|
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.retry import retry_pending_with_retries
|
||||||
from plyngent.cli.selection import select_model, select_provider
|
from plyngent.cli.selection import select_model, select_provider
|
||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
@@ -117,7 +119,7 @@ class ProviderNameParam(click.ParamType[str]):
|
|||||||
state = _repl_state(ctx)
|
state = _repl_state(ctx)
|
||||||
if state is None:
|
if state is None:
|
||||||
return []
|
return []
|
||||||
return _filter_choices(incomplete, sorted(state.config.providers.keys()))
|
return _filter_choices(incomplete, sorted(state.config.selectable_providers().keys()))
|
||||||
|
|
||||||
|
|
||||||
PROVIDER_NAME = ProviderNameParam()
|
PROVIDER_NAME = ProviderNameParam()
|
||||||
@@ -137,7 +139,7 @@ class ModelIdParam(click.ParamType[str]):
|
|||||||
state = _repl_state(ctx)
|
state = _repl_state(ctx)
|
||||||
if state is None:
|
if state is None:
|
||||||
return []
|
return []
|
||||||
return _filter_choices(incomplete, sorted(state.provider.models.keys()))
|
return _filter_choices(incomplete, state.model_choice_ids())
|
||||||
|
|
||||||
|
|
||||||
MODEL_ID = ModelIdParam()
|
MODEL_ID = ModelIdParam()
|
||||||
@@ -316,6 +318,12 @@ def config_cmd(state: ReplState) -> None:
|
|||||||
click.secho(f"error: config reload failed: {exc}", fg="red")
|
click.secho(f"error: config reload failed: {exc}", fg="red")
|
||||||
click.echo(f"config file: {path}")
|
click.echo(f"config file: {path}")
|
||||||
return
|
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:
|
if state.config.bad_providers:
|
||||||
names = ", ".join(sorted(state.config.bad_providers.keys()))
|
names = ", ".join(sorted(state.config.bad_providers.keys()))
|
||||||
click.secho(f"warning: ignored bad providers: {names}", fg="yellow")
|
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}")
|
click.echo(f"provider={state.provider_name}")
|
||||||
return
|
return
|
||||||
try:
|
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
|
prev_model = state.model
|
||||||
state.provider_name = pname
|
state.provider_name = pname
|
||||||
state.provider = provider
|
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
|
state.model = prev_model
|
||||||
else:
|
else:
|
||||||
# Current model not on the new provider — pick one (prompt when interactive).
|
# Current model not on the new provider — pick one (prompt when interactive).
|
||||||
try:
|
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:
|
except click.ClickException as exc:
|
||||||
click.echo(f"error: switched provider but model selection failed: {exc}")
|
click.echo(f"error: switched provider but model selection failed: {exc}")
|
||||||
return
|
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}",
|
f"model {prev_model!r} is not available on {pname}; using {state.model!r}",
|
||||||
fg="yellow",
|
fg="yellow",
|
||||||
)
|
)
|
||||||
state.rebuild_client()
|
state.rebuild_client()
|
||||||
_await(state.persist_llm_selection())
|
_await(state.persist_llm_selection())
|
||||||
click.echo(f"switched provider to {pname} model={state.model}")
|
click.echo(f"switched provider to {pname} model={state.model}")
|
||||||
except (click.ClickException, ProviderNotSupportedError) as exc:
|
except (click.ClickException, ProviderNotSupportedError) as exc:
|
||||||
click.echo(f"error: {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")
|
@slash.command("model")
|
||||||
@click.argument("model_id", required=False, type=MODEL_ID)
|
@click.argument("model_id", required=False, type=MODEL_ID)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def model_cmd(state: ReplState, model_id: str | None) -> None:
|
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:
|
if not model_id:
|
||||||
click.echo(f"model={state.model}")
|
click.echo(f"model={state.model}")
|
||||||
return
|
return
|
||||||
try:
|
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()
|
state.rebuild_client()
|
||||||
_await(state.persist_llm_selection())
|
_await(state.persist_llm_selection())
|
||||||
click.echo(f"switched model to {state.model}")
|
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'}")
|
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")
|
@slash.command("rounds")
|
||||||
@click.argument("n", required=False, type=int)
|
@click.argument("n", required=False, type=int)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
@@ -749,7 +853,8 @@ async def handle_slash(state: ReplState, line: str) -> bool:
|
|||||||
if not body:
|
if not body:
|
||||||
return True
|
return True
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
click.echo(f"error: {exc}")
|
click.echo(f"error: {exc}")
|
||||||
return True
|
return True
|
||||||
|
|||||||
+105
-15
@@ -1,11 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
|
from plyngent.agent import ChatAgent, ChatClient, ToolRegistry
|
||||||
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
|
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.memory.database.store import normalize_workspace
|
||||||
from plyngent.runtime import create_client
|
from plyngent.runtime import create_client
|
||||||
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
|
from plyngent.tools import DEFAULT_TOOLS, set_workspace_root
|
||||||
@@ -31,6 +40,8 @@ class ReplState:
|
|||||||
max_rounds: int = DEFAULT_MAX_ROUNDS
|
max_rounds: int = DEFAULT_MAX_ROUNDS
|
||||||
stream_enabled: bool = True
|
stream_enabled: bool = True
|
||||||
verbose: bool = False
|
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.
|
# One-shot / scripts: never prompt to raise tool-loop limits.
|
||||||
interactive_limits: bool = True
|
interactive_limits: bool = True
|
||||||
# When False, skip destructive-tool confirms (e.g. --yes).
|
# When False, skip destructive-tool confirms (e.g. --yes).
|
||||||
@@ -40,6 +51,11 @@ class ReplState:
|
|||||||
client: ChatClient = field(init=False)
|
client: ChatClient = field(init=False)
|
||||||
agent: ChatAgent = field(init=False)
|
agent: ChatAgent = field(init=False)
|
||||||
session_id: int | None = None
|
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:
|
def __post_init__(self) -> None:
|
||||||
# DeepSeek client uses a compatible but distinct param type; treat as ChatClient.
|
# DeepSeek client uses a compatible but distinct param type; treat as ChatClient.
|
||||||
@@ -49,9 +65,10 @@ class ReplState:
|
|||||||
self.sync_display_flags()
|
self.sync_display_flags()
|
||||||
|
|
||||||
def sync_display_flags(self) -> None:
|
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_verbose_tool_results(self.verbose)
|
||||||
|
set_markdown_enabled(self.markdown_enabled)
|
||||||
|
|
||||||
def _workspace_key(self) -> str:
|
def _workspace_key(self) -> str:
|
||||||
key = normalize_workspace(self.workspace)
|
key = normalize_workspace(self.workspace)
|
||||||
@@ -110,6 +127,73 @@ class ReplState:
|
|||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
self.agent.messages = messages
|
self.agent.messages = messages
|
||||||
self.sync_display_flags()
|
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:
|
def reload_config_from_disk(self) -> None:
|
||||||
"""Re-read TOML config and re-bind provider/model when still valid."""
|
"""Re-read TOML config and re-bind provider/model when still valid."""
|
||||||
@@ -122,27 +206,32 @@ class ReplState:
|
|||||||
self.config.reload()
|
self.config.reload()
|
||||||
set_path_denylist(self.config.agent_config.path_denylist or None)
|
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
|
preferred_model = self.model
|
||||||
try:
|
try:
|
||||||
pname, provider = select_provider(
|
pname, provider = select_provider(
|
||||||
self.config.providers,
|
selectable,
|
||||||
preferred=preferred_provider,
|
preferred=preferred_provider,
|
||||||
interactive=False,
|
interactive=False,
|
||||||
)
|
)
|
||||||
except (click.ClickException, ProviderNotSupportedError) as exc:
|
except (click.ClickException, ProviderNotSupportedError) as exc:
|
||||||
msg = f"config reloaded but provider selection failed: {exc}"
|
msg = f"config reloaded but provider selection failed: {exc}"
|
||||||
raise ValueError(msg) from 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:
|
try:
|
||||||
model_id = select_model(provider, preferred=preferred_model, interactive=False)
|
model_id = select_model(provider, preferred=preferred_model, interactive=False)
|
||||||
except click.ClickException:
|
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
|
model_id = next(iter(sorted(provider.models.keys()))) if provider.models else preferred_model
|
||||||
|
|
||||||
self.provider_name = pname
|
self.provider_name = pname
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.model = model_id
|
self.model = model_id
|
||||||
self.rebuild_client()
|
self.rebuild_client()
|
||||||
|
self.invalidate_remote_models()
|
||||||
|
|
||||||
def _set_workspace(self, path: Path) -> None:
|
def _set_workspace(self, path: Path) -> None:
|
||||||
"""Update REPL + tool workspace root."""
|
"""Update REPL + tool workspace root."""
|
||||||
@@ -175,16 +264,22 @@ class ReplState:
|
|||||||
from plyngent.cli.selection import select_provider
|
from plyngent.cli.selection import select_provider
|
||||||
from plyngent.runtime import ProviderNotSupportedError
|
from plyngent.runtime import ProviderNotSupportedError
|
||||||
|
|
||||||
if pname not in self.config.providers:
|
if pname not in self.config.selectable_providers():
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
name, provider = select_provider(
|
name, provider = select_provider(
|
||||||
self.config.providers,
|
self.config.selectable_providers(),
|
||||||
preferred=pname,
|
preferred=pname,
|
||||||
interactive=False,
|
interactive=False,
|
||||||
)
|
)
|
||||||
except click.ClickException, ProviderNotSupportedError:
|
except click.ClickException, ProviderNotSupportedError:
|
||||||
return False
|
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:
|
if name != self.provider_name or provider is not self.provider:
|
||||||
self.provider_name = name
|
self.provider_name = name
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
@@ -192,16 +287,11 @@ class ReplState:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _try_set_model(self, model_id: str) -> bool:
|
def _try_set_model(self, model_id: str) -> bool:
|
||||||
import click
|
token = model_id.strip()
|
||||||
|
if not token:
|
||||||
from plyngent.cli.selection import select_model
|
|
||||||
|
|
||||||
try:
|
|
||||||
resolved = select_model(self.provider, preferred=model_id, interactive=False)
|
|
||||||
except click.ClickException:
|
|
||||||
return False
|
return False
|
||||||
if resolved != self.model:
|
if token != self.model:
|
||||||
self.model = resolved
|
self.model = token
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
from msgspec import Struct, field
|
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)
|
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"):
|
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"):
|
class OpenAICompatibleProvider(ProviderConfig, tag="openai-compatible"):
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ from typing import TYPE_CHECKING, cast
|
|||||||
import msgspec
|
import msgspec
|
||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from .models import AgentConfig, DatabaseConfig, Provider
|
from .models import AgentConfig, DatabaseConfig, ModelConfig, Provider
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping, MutableMapping
|
from collections.abc import Mapping, MutableMapping, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -35,28 +35,39 @@ def _parse_agent(raw: dict[str, object]) -> AgentConfig:
|
|||||||
|
|
||||||
def _parse_providers(
|
def _parse_providers(
|
||||||
document: tomlkit.TOMLDocument,
|
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.
|
"""Parse provider entries from the document.
|
||||||
|
|
||||||
Returns:
|
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] = {}
|
providers: dict[str, Provider] = {}
|
||||||
bad_providers: dict[str, object] = {}
|
bad_providers: dict[str, object] = {}
|
||||||
|
recoverable: dict[str, Provider] = {}
|
||||||
|
|
||||||
raw: dict[str, object] = document.unwrap()
|
raw: dict[str, object] = document.unwrap()
|
||||||
providers_raw: object = raw.get("providers", {})
|
providers_raw: object = raw.get("providers", {})
|
||||||
if not isinstance(providers_raw, dict):
|
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():
|
for name, raw_entry in cast("dict[str, object]", providers_raw).items():
|
||||||
if not isinstance(raw_entry, dict):
|
if not isinstance(raw_entry, dict):
|
||||||
bad_providers[name] = raw_entry
|
bad_providers[name] = raw_entry
|
||||||
continue
|
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 tagged-union dispatch via msgspec
|
||||||
try:
|
try:
|
||||||
provider: Provider = msgspec.convert(raw_entry, Provider)
|
provider: Provider = msgspec.convert(entry, Provider)
|
||||||
except msgspec.ValidationError:
|
except msgspec.ValidationError:
|
||||||
bad_providers[name] = raw_entry
|
bad_providers[name] = raw_entry
|
||||||
continue
|
continue
|
||||||
@@ -66,19 +77,20 @@ def _parse_providers(
|
|||||||
cls = type(provider)
|
cls = type(provider)
|
||||||
known = set(cls.__struct_fields__)
|
known = set(cls.__struct_fields__)
|
||||||
known.add("preset") # tag_field, excluded from __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
|
bad_providers[name] = raw_entry
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Usable providers must list at least one model (DeepSeek seeds defaults
|
# Empty models: recoverable. OpenAI/DeepSeek seed defaults when
|
||||||
# when models is omitted; an explicit empty models={} is invalid).
|
# ``models`` is omitted; only explicit models={} lands here.
|
||||||
if not provider.models:
|
if not provider.models:
|
||||||
bad_providers[name] = {**cast("dict[str, object]", raw_entry), "_reason": "no models"}
|
recoverable[name] = provider
|
||||||
continue
|
continue
|
||||||
|
|
||||||
providers[name] = provider
|
providers[name] = provider
|
||||||
|
|
||||||
return providers, bad_providers
|
return providers, bad_providers, recoverable
|
||||||
|
|
||||||
|
|
||||||
class ConfigStore:
|
class ConfigStore:
|
||||||
@@ -90,6 +102,7 @@ class ConfigStore:
|
|||||||
_agent: AgentConfig
|
_agent: AgentConfig
|
||||||
_providers: dict[str, Provider]
|
_providers: dict[str, Provider]
|
||||||
_bad_providers: dict[str, object]
|
_bad_providers: dict[str, object]
|
||||||
|
_recoverable_providers: dict[str, Provider]
|
||||||
|
|
||||||
def __init__(self, path: Path, document: tomlkit.TOMLDocument) -> None:
|
def __init__(self, path: Path, document: tomlkit.TOMLDocument) -> None:
|
||||||
self._path = path
|
self._path = path
|
||||||
@@ -97,7 +110,7 @@ class ConfigStore:
|
|||||||
raw: dict[str, object] = document.unwrap()
|
raw: dict[str, object] = document.unwrap()
|
||||||
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
|
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
|
||||||
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
|
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
|
@property
|
||||||
def path(self) -> Path:
|
def path(self) -> Path:
|
||||||
@@ -135,9 +148,10 @@ class ConfigStore:
|
|||||||
|
|
||||||
@providers.setter
|
@providers.setter
|
||||||
def providers(self, value: Mapping[str, Provider]) -> None: # pyright: ignore[reportPropertyTypeMismatch]
|
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._providers = dict(value)
|
||||||
self._bad_providers = {}
|
self._bad_providers = {}
|
||||||
|
self._recoverable_providers = {}
|
||||||
|
|
||||||
# -- bad_providers (read-only) --
|
# -- bad_providers (read-only) --
|
||||||
|
|
||||||
@@ -146,6 +160,46 @@ class ConfigStore:
|
|||||||
"""Read-only view of unrecognised / malformed provider entries."""
|
"""Read-only view of unrecognised / malformed provider entries."""
|
||||||
return MappingProxyType(self._bad_providers)
|
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 --
|
# -- persistence --
|
||||||
|
|
||||||
def write(self) -> None:
|
def write(self) -> None:
|
||||||
@@ -162,7 +216,7 @@ class ConfigStore:
|
|||||||
raw: dict[str, object] = self._document.unwrap()
|
raw: dict[str, object] = self._document.unwrap()
|
||||||
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
|
self._database = _parse_database(cast("dict[str, object]", raw.get("database", {})))
|
||||||
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
|
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 --
|
# -- internal sync helpers --
|
||||||
|
|
||||||
@@ -194,14 +248,15 @@ class ConfigStore:
|
|||||||
self._sync_section("agent", self._agent)
|
self._sync_section("agent", self._agent)
|
||||||
|
|
||||||
def _sync_providers_section(self) -> None:
|
def _sync_providers_section(self) -> None:
|
||||||
"""Sync ``[providers]`` to the document."""
|
"""Sync ``[providers]`` to the document (ready + recoverable)."""
|
||||||
section = self._toml_table("providers")
|
section = self._toml_table("providers")
|
||||||
|
keep = set(self._providers) | set(self._recoverable_providers)
|
||||||
|
|
||||||
for name in list(section.keys()):
|
for name in list(section.keys()):
|
||||||
if name not in self._providers:
|
if name not in keep:
|
||||||
del section[name]
|
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)
|
raw: dict[str, object] = msgspec.to_builtins(provider)
|
||||||
if name in section:
|
if name in section:
|
||||||
entry = cast("MutableMapping[str, object]", section[name])
|
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 BaseOpenAIClient as BaseOpenAIClient
|
||||||
from .client import OpenAIClient as OpenAIClient
|
from .client import OpenAIClient as OpenAIClient
|
||||||
|
from .client import OpenAICompatibleClient as OpenAICompatibleClient
|
||||||
from .config import OpenAIConfig as OpenAIConfig
|
from .config import OpenAIConfig as OpenAIConfig
|
||||||
from .model import AnyAssistantToolCall as AnyAssistantToolCall
|
from .model import AnyAssistantToolCall as AnyAssistantToolCall
|
||||||
from .model import AnyChatMessage as AnyChatMessage
|
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 GrammarSyntax as GrammarSyntax
|
||||||
from .model import JsonObjectResponseFormat as JsonObjectResponseFormat
|
from .model import JsonObjectResponseFormat as JsonObjectResponseFormat
|
||||||
from .model import Modality as Modality
|
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 NamedChatMessage as NamedChatMessage
|
||||||
from .model import NamedRole as NamedRole
|
from .model import NamedRole as NamedRole
|
||||||
from .model import ReasoningEffort as ReasoningEffort
|
from .model import ReasoningEffort as ReasoningEffort
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .model import (
|
|||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
ChatCompletionResponse,
|
ChatCompletionResponse,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
|
ModelsResponse,
|
||||||
StreamToolCallDelta,
|
StreamToolCallDelta,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ _HTTP_ERROR = 400
|
|||||||
_ERROR_BODY_PREVIEW = 500
|
_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()."""
|
"""Close a streaming response; support sync or async close()."""
|
||||||
for name in ("aclose", "close"):
|
for name in ("aclose", "close"):
|
||||||
method = getattr(resp, name, None)
|
method = getattr(resp, name, None)
|
||||||
@@ -41,6 +42,10 @@ async def _close_async_response(resp: AsyncResponse) -> None:
|
|||||||
return
|
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:
|
async def read_response_body(resp: object) -> bytes | str | None:
|
||||||
"""Read response body; niquests ``AsyncResponse.content`` is an async property."""
|
"""Read response body; niquests ``AsyncResponse.content`` is an async property."""
|
||||||
content = getattr(resp, "content", None)
|
content = getattr(resp, "content", None)
|
||||||
@@ -76,11 +81,16 @@ def sse_data_payload(line: bytes) -> bytes | None | Literal[False]:
|
|||||||
return payload
|
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``."""
|
"""Return an error string for HTTP ``status_code`` >= 400, else ``None``."""
|
||||||
if status_code < _HTTP_ERROR:
|
if status_code < _HTTP_ERROR:
|
||||||
return None
|
return None
|
||||||
msg = f"chat completions HTTP {status_code}"
|
msg = f"{what} HTTP {status_code}"
|
||||||
if body is None:
|
if body is None:
|
||||||
return msg
|
return msg
|
||||||
if isinstance(body, (bytes, bytearray)):
|
if isinstance(body, (bytes, bytearray)):
|
||||||
@@ -91,10 +101,13 @@ def http_error_message(status_code: int, body: bytes | str | None = None) -> str
|
|||||||
|
|
||||||
|
|
||||||
class BaseOpenAIClient:
|
class BaseOpenAIClient:
|
||||||
|
"""Shared OpenAI-compatible HTTP surface (session, models, chat SSE)."""
|
||||||
|
|
||||||
session: AsyncSession
|
session: AsyncSession
|
||||||
encoder: msgspec.json.Encoder
|
encoder: msgspec.json.Encoder
|
||||||
decoder: msgspec.json.Decoder[ChatCompletionResponse]
|
decoder: msgspec.json.Decoder[ChatCompletionResponse]
|
||||||
chunk_decoder: msgspec.json.Decoder[ChatCompletionChunk]
|
chunk_decoder: msgspec.json.Decoder[ChatCompletionChunk]
|
||||||
|
models_decoder: msgspec.json.Decoder[ModelsResponse]
|
||||||
|
|
||||||
def __init__(self, config: OpenAIConfig) -> None:
|
def __init__(self, config: OpenAIConfig) -> None:
|
||||||
self.session = niquests.AsyncSession(
|
self.session = niquests.AsyncSession(
|
||||||
@@ -104,8 +117,20 @@ class BaseOpenAIClient:
|
|||||||
self.encoder = msgspec.json.Encoder()
|
self.encoder = msgspec.json.Encoder()
|
||||||
self.decoder = msgspec.json.Decoder(ChatCompletionResponse)
|
self.decoder = msgspec.json.Decoder(ChatCompletionResponse)
|
||||||
self.chunk_decoder = msgspec.json.Decoder(ChatCompletionChunk)
|
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)."""
|
"""Raise if the HTTP response is an error (stream or non-stream)."""
|
||||||
status = getattr(resp, "status_code", None)
|
status = getattr(resp, "status_code", None)
|
||||||
if not isinstance(status, int):
|
if not isinstance(status, int):
|
||||||
@@ -115,7 +140,7 @@ class BaseOpenAIClient:
|
|||||||
body: bytes | str | None = None
|
body: bytes | str | None = None
|
||||||
if status >= _HTTP_ERROR:
|
if status >= _HTTP_ERROR:
|
||||||
body = await read_response_body(resp)
|
body = await read_response_body(resp)
|
||||||
msg = http_error_message(status, body)
|
msg = http_error_message(status, body, what=what)
|
||||||
if msg is None:
|
if msg is None:
|
||||||
return
|
return
|
||||||
if hasattr(resp, "close") or hasattr(resp, "aclose"):
|
if hasattr(resp, "close") or hasattr(resp, "aclose"):
|
||||||
@@ -125,7 +150,7 @@ class BaseOpenAIClient:
|
|||||||
async def _parse_sse(self, resp: AsyncResponse) -> AsyncIterator[ChatCompletionChunk]:
|
async def _parse_sse(self, resp: AsyncResponse) -> AsyncIterator[ChatCompletionChunk]:
|
||||||
"""Yield SSE chunks; stop at ``data: [DONE]`` so we do not hang on keep-alive."""
|
"""Yield SSE chunks; stop at ``data: [DONE]`` so we do not hang on keep-alive."""
|
||||||
try:
|
try:
|
||||||
await self._ensure_ok(resp)
|
await self._ensure_ok(resp, what="chat completions")
|
||||||
async for raw in resp.iter_lines():
|
async for raw in resp.iter_lines():
|
||||||
if not raw:
|
if not raw:
|
||||||
continue
|
continue
|
||||||
@@ -138,8 +163,21 @@ class BaseOpenAIClient:
|
|||||||
finally:
|
finally:
|
||||||
await _close_async_response(resp)
|
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:
|
def __init__(self, config: OpenAIConfig) -> None:
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
|
|
||||||
@@ -173,15 +211,12 @@ class OpenAIClient(BaseOpenAIClient):
|
|||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
stream=False,
|
stream=False,
|
||||||
)
|
)
|
||||||
await self._ensure_ok(resp)
|
body = await self._read_json_body(resp, what="chat completions")
|
||||||
body = await read_response_body(resp)
|
return self.decoder.decode(body)
|
||||||
if body is None:
|
|
||||||
msg = "chat completions response body is empty"
|
|
||||||
raise RuntimeError(msg)
|
# Backward-compatible alias: many call sites still import OpenAIClient from this package.
|
||||||
if not isinstance(body, (bytes, bytearray)):
|
OpenAIClient = OpenAICompatibleClient
|
||||||
msg = f"chat completions response body has unexpected type {type(body)!r}"
|
|
||||||
raise TypeError(msg)
|
|
||||||
return self.decoder.decode(bytes(body))
|
|
||||||
|
|
||||||
|
|
||||||
def merge_stream_tool_calls(deltas: list[StreamToolCallDelta]) -> list[AssistantFunctionToolCall]:
|
def merge_stream_tool_calls(deltas: list[StreamToolCallDelta]) -> list[AssistantFunctionToolCall]:
|
||||||
|
|||||||
@@ -258,3 +258,19 @@ class ChatCompletionChunk(Struct):
|
|||||||
choices: list[ChunkChoice]
|
choices: list[ChunkChoice]
|
||||||
# Final usage chunk may omit or null usage depending on provider.
|
# Final usage chunk may omit or null usage depending on provider.
|
||||||
usage: dict[str, Any] | None | Unset = UNSET
|
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,
|
Provider,
|
||||||
)
|
)
|
||||||
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
|
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:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from plyngent.agent.responses_client import ResponsesChatClient
|
||||||
|
|
||||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||||
DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
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):
|
class ProviderNotSupportedError(NotImplementedError):
|
||||||
@@ -45,17 +50,25 @@ def _deepseek_convention(extras: Mapping[str, str]) -> str:
|
|||||||
return extras.get("convention", "openai").lower()
|
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.
|
"""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:
|
Raises:
|
||||||
ProviderNotSupportedError: When the provider preset (or DeepSeek convention)
|
ProviderNotSupportedError: When the provider preset (or DeepSeek convention)
|
||||||
has no implemented client yet.
|
has no implemented client yet.
|
||||||
"""
|
"""
|
||||||
if isinstance(provider, OpenAIProvider):
|
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):
|
if isinstance(provider, OpenAICompatibleProvider):
|
||||||
return OpenAIClient(provider_to_openai_config(provider))
|
return OpenAICompatibleClient(provider_to_openai_config(provider))
|
||||||
if isinstance(provider, DeepseekProvider):
|
if isinstance(provider, DeepseekProvider):
|
||||||
convention = _deepseek_convention(provider.extras)
|
convention = _deepseek_convention(provider.extras)
|
||||||
if convention in {"openai", "openai_compat", "openai-compatible"}:
|
if convention in {"openai", "openai_compat", "openai-compatible"}:
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ DEFAULT_MAX_MATCHES = 200
|
|||||||
|
|
||||||
def _rel(path: Path, root: Path) -> str:
|
def _rel(path: Path, root: Path) -> str:
|
||||||
try:
|
try:
|
||||||
return str(path.relative_to(root))
|
return path.relative_to(root).as_posix()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return str(path)
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
def _hidden_or_vcs(parts: tuple[str, ...]) -> bool:
|
def _hidden_or_vcs(parts: tuple[str, ...]) -> bool:
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ from .pty_session import PtyManager
|
|||||||
|
|
||||||
@tool
|
@tool
|
||||||
def open_pty(command: list[str], *, cwd: str = ".") -> str:
|
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.
|
POSIX uses openpty/fork; Windows uses ConPTY (pywinpty). Returns structured
|
||||||
Failed exec surfaces via later read_pty data (marker) and exit_code=127.
|
status including session_id. Failed exec may surface via later read_pty
|
||||||
|
data (marker) and a non-zero exit_code.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
session = PtyManager.open(command, cwd=cwd)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
import pty
|
|
||||||
import select
|
|
||||||
import signal
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
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
|
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -27,15 +23,12 @@ DEFAULT_SESSION_OUTPUT_BUDGET = 256_000
|
|||||||
DEFAULT_CLOSE_GRACE_SECONDS = 0.5
|
DEFAULT_CLOSE_GRACE_SECONDS = 0.5
|
||||||
_SESSION_LIMIT_STEP = 4
|
_SESSION_LIMIT_STEP = 4
|
||||||
_BUDGET_STEP = 256_000
|
_BUDGET_STEP = 256_000
|
||||||
_STDERR_FD = 2
|
|
||||||
_EXEC_FAIL_MARKER = b"plyngent-pty-exec-failed: "
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PtySession:
|
class PtySession:
|
||||||
session_id: int
|
session_id: int
|
||||||
master_fd: int
|
handle: PtyHandle
|
||||||
pid: int
|
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
alive: bool = True
|
alive: bool = True
|
||||||
exit_code: int | None = None
|
exit_code: int | None = None
|
||||||
@@ -45,6 +38,16 @@ class PtySession:
|
|||||||
output_budget: int = DEFAULT_SESSION_OUTPUT_BUDGET
|
output_budget: int = DEFAULT_SESSION_OUTPUT_BUDGET
|
||||||
command: tuple[str, ...] = ()
|
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)
|
@dataclass(frozen=True)
|
||||||
class PtyReadResult:
|
class PtyReadResult:
|
||||||
@@ -114,6 +117,9 @@ class PtyManager:
|
|||||||
*,
|
*,
|
||||||
cwd: str = ".",
|
cwd: str = ".",
|
||||||
) -> PtySession:
|
) -> PtySession:
|
||||||
|
if not pty_available():
|
||||||
|
msg = "PTY is not available on this platform (Windows needs pywinpty)"
|
||||||
|
raise WorkspaceError(msg)
|
||||||
check_command_allowed(command)
|
check_command_allowed(command)
|
||||||
workdir = resolve_path(cwd)
|
workdir = resolve_path(cwd)
|
||||||
if not workdir.is_dir():
|
if not workdir.is_dir():
|
||||||
@@ -131,37 +137,18 @@ class PtyManager:
|
|||||||
msg = f"{reason}; close idle sessions or allow a higher limit"
|
msg = f"{reason}; close idle sessions or allow a higher limit"
|
||||||
raise WorkspaceError(msg)
|
raise WorkspaceError(msg)
|
||||||
|
|
||||||
master_fd, slave_fd = pty.openpty()
|
try:
|
||||||
# Parent must not pass master into the child (or future children).
|
handle = spawn_pty(command, cwd=workdir)
|
||||||
with contextlib.suppress(OSError, AttributeError):
|
except OSError as exc:
|
||||||
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
|
msg = f"failed to open PTY: {exc}"
|
||||||
pid = os.fork()
|
raise WorkspaceError(msg) from exc
|
||||||
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)
|
|
||||||
|
|
||||||
os.close(slave_fd)
|
|
||||||
with contextlib.suppress(OSError, AttributeError):
|
|
||||||
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
|
|
||||||
with cls._lock:
|
with cls._lock:
|
||||||
session_id = cls._next_id
|
session_id = cls._next_id
|
||||||
cls._next_id += 1
|
cls._next_id += 1
|
||||||
session = PtySession(
|
session = PtySession(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
master_fd=master_fd,
|
handle=handle,
|
||||||
pid=pid,
|
|
||||||
command=tuple(command),
|
command=tuple(command),
|
||||||
output_budget=cls.session_output_budget,
|
output_budget=cls.session_output_budget,
|
||||||
)
|
)
|
||||||
@@ -181,20 +168,10 @@ class PtyManager:
|
|||||||
def _poll_exit(cls, session: PtySession) -> None:
|
def _poll_exit(cls, session: PtySession) -> None:
|
||||||
if not session.alive or session.closed:
|
if not session.alive or session.closed:
|
||||||
return
|
return
|
||||||
try:
|
alive, exit_code = session.handle.poll_exit()
|
||||||
waited_pid, status = os.waitpid(session.pid, os.WNOHANG)
|
session.alive = alive
|
||||||
except ChildProcessError:
|
if exit_code is not None:
|
||||||
session.alive = False
|
session.exit_code = exit_code
|
||||||
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
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def refresh(cls, session_id: int) -> PtySession:
|
def refresh(cls, session_id: int) -> PtySession:
|
||||||
@@ -210,13 +187,7 @@ class PtyManager:
|
|||||||
if session.closed or (session.output_budget - session.bytes_read) <= 0:
|
if session.closed or (session.output_budget - session.bytes_read) <= 0:
|
||||||
return b""
|
return b""
|
||||||
to_read = min(max_bytes, session.output_budget - session.bytes_read)
|
to_read = min(max_bytes, session.output_budget - session.bytes_read)
|
||||||
data = b""
|
data = session.handle.read_bytes(to_read, timeout)
|
||||||
try:
|
|
||||||
ready, _, _ = select.select([session.master_fd], [], [], timeout)
|
|
||||||
if ready:
|
|
||||||
data = os.read(session.master_fd, to_read)
|
|
||||||
except OSError, ValueError:
|
|
||||||
data = b""
|
|
||||||
cls._poll_exit(session)
|
cls._poll_exit(session)
|
||||||
if not data:
|
if not data:
|
||||||
return b""
|
return b""
|
||||||
@@ -336,7 +307,7 @@ class PtyManager:
|
|||||||
msg = f"PTY session process is not alive: {session_id}"
|
msg = f"PTY session process is not alive: {session_id}"
|
||||||
raise WorkspaceError(msg)
|
raise WorkspaceError(msg)
|
||||||
try:
|
try:
|
||||||
_ = os.write(session.master_fd, data.encode())
|
session.handle.write_bytes(data.encode())
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
cls._poll_exit(session)
|
cls._poll_exit(session)
|
||||||
msg = f"failed to write PTY: {exc}"
|
msg = f"failed to write PTY: {exc}"
|
||||||
@@ -366,27 +337,19 @@ class PtyManager:
|
|||||||
|
|
||||||
cls._poll_exit(session)
|
cls._poll_exit(session)
|
||||||
if session.alive:
|
if session.alive:
|
||||||
with contextlib.suppress(ProcessLookupError):
|
session.handle.terminate()
|
||||||
os.kill(session.pid, signal.SIGTERM)
|
session.handle.wait_reap(timeout=max(0.0, grace_seconds))
|
||||||
deadline = time.monotonic() + max(0.0, grace_seconds)
|
cls._poll_exit(session)
|
||||||
while session.alive and time.monotonic() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
cls._poll_exit(session)
|
|
||||||
if session.alive:
|
if session.alive:
|
||||||
with contextlib.suppress(ProcessLookupError):
|
session.handle.kill()
|
||||||
os.kill(session.pid, signal.SIGKILL)
|
session.handle.wait_reap(timeout=1.0)
|
||||||
with contextlib.suppress(ChildProcessError):
|
cls._poll_exit(session)
|
||||||
_ = os.waitpid(session.pid, 0)
|
if session.alive:
|
||||||
session.alive = False
|
session.alive = False
|
||||||
if session.exit_code is None:
|
if session.exit_code is None:
|
||||||
session.exit_code = -signal.SIGKILL
|
session.exit_code = session.handle.poll_exit()[1]
|
||||||
else:
|
|
||||||
# Ensure reaped
|
|
||||||
with contextlib.suppress(ChildProcessError):
|
|
||||||
_ = os.waitpid(session.pid, os.WNOHANG)
|
|
||||||
|
|
||||||
with contextlib.suppress(OSError):
|
session.handle.close_resources()
|
||||||
os.close(session.master_fd)
|
|
||||||
session.closed = True
|
session.closed = True
|
||||||
with cls._lock:
|
with cls._lock:
|
||||||
_ = cls._sessions.pop(session_id, None)
|
_ = cls._sessions.pop(session_id, None)
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ def resolve_path(path: str | Path) -> Path:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
msg = f"path escapes workspace root ({root}): {path}"
|
msg = f"path escapes workspace root ({root}): {path}"
|
||||||
raise WorkspaceError(msg) from exc
|
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:
|
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}"
|
msg = f"path denied by policy (matched {pattern!r}): {path}"
|
||||||
raise WorkspaceError(msg)
|
raise WorkspaceError(msg)
|
||||||
return resolved
|
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
|
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:
|
def test_chars_to_tokens() -> None:
|
||||||
assert chars_to_tokens(0) == 0
|
assert chars_to_tokens(0) == 0
|
||||||
assert chars_to_tokens(1) == 1
|
assert chars_to_tokens(1) == 1
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from plyngent.agent import ReasoningDeltaEvent, TextDeltaEvent, ToolResultEvent
|
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
|
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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:
|
async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
set_markdown_enabled(False)
|
||||||
await render_events(
|
await render_events(
|
||||||
_aiter(
|
_aiter(
|
||||||
[
|
[
|
||||||
@@ -33,12 +41,14 @@ async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) ->
|
|||||||
assert "think" in out
|
assert "think" in out
|
||||||
assert "assistant: " in out
|
assert "assistant: " in out
|
||||||
assert "hello" in out
|
assert "hello" in out
|
||||||
|
set_markdown_enabled(True)
|
||||||
|
|
||||||
|
|
||||||
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
|
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
long = "x" * 200
|
long = "x" * 200
|
||||||
msg = ToolChatMessage(content=long, tool_call_id="1")
|
msg = ToolChatMessage(content=long, tool_call_id="1")
|
||||||
set_verbose_tool_results(False)
|
set_verbose_tool_results(False)
|
||||||
|
set_markdown_enabled(False)
|
||||||
await render_events(_aiter([ToolResultEvent(message=msg)]))
|
await render_events(_aiter([ToolResultEvent(message=msg)]))
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "…" in out
|
assert "…" in out
|
||||||
@@ -49,3 +59,49 @@ async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]
|
|||||||
out2 = capsys.readouterr().out
|
out2 = capsys.readouterr().out
|
||||||
assert long in out2
|
assert long in out2
|
||||||
set_verbose_tool_results(False)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
import pytest
|
||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from plyngent.cli.readline_setup import (
|
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.models import ModelConfig, OpenAICompatibleProvider, OpenAIProvider
|
||||||
from plyngent.config.store import ConfigStore
|
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:
|
def _minimal_state(tmp_path: object) -> ReplState:
|
||||||
from pathlib import Path
|
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.
|
# When switching to b, only-a is missing → select_model is invoked interactively.
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"plyngent.cli.slash.select_model",
|
"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 await handle_slash(state, "/provider b") is True
|
||||||
assert state.provider_name == "b"
|
assert state.provider_name == "b"
|
||||||
|
|||||||
@@ -64,11 +64,25 @@ def test_select_provider_interactive_choose() -> None:
|
|||||||
assert name == "b"
|
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(
|
provider = OpenAICompatibleProvider(
|
||||||
access_key_or_token="sk",
|
access_key_or_token="sk",
|
||||||
url="https://x/v1",
|
url="https://x/v1",
|
||||||
models={"m1": ModelConfig()},
|
models={"m1": ModelConfig()},
|
||||||
)
|
)
|
||||||
with pytest.raises(Exception, match="unknown model"):
|
assert select_model(provider, preferred="nope") == "nope"
|
||||||
_ = select_model(provider, preferred="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"}
|
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:
|
def test_deepseek_explicit_models_override_defaults() -> None:
|
||||||
from plyngent.config import ModelConfig
|
from plyngent.config import ModelConfig
|
||||||
|
|
||||||
@@ -81,7 +108,7 @@ def test_read_bad_config() -> None:
|
|||||||
assert isinstance(config.bad_providers, Mapping)
|
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 = tmp_path / "empty-models.toml"
|
||||||
_ = path.write_text(
|
_ = path.write_text(
|
||||||
"""
|
"""
|
||||||
@@ -95,7 +122,29 @@ models = {}
|
|||||||
)
|
)
|
||||||
config = plyngent.config.load(path)
|
config = plyngent.config.load(path)
|
||||||
assert "hollow" not in config.providers
|
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:
|
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,
|
OpenAIProvider,
|
||||||
)
|
)
|
||||||
from plyngent.lmproto.deepseek import DeepseekOpenAIClient
|
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
|
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)
|
config = provider_to_openai_config(provider)
|
||||||
assert config.access_key_or_token == "sk-test"
|
assert config.access_key_or_token == "sk-test"
|
||||||
assert config.base_url == "https://api.openai.com/v1"
|
assert config.base_url == "https://api.openai.com/v1"
|
||||||
|
from plyngent.agent.responses_client import ResponsesChatClient
|
||||||
|
|
||||||
client = create_client(provider)
|
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:
|
def test_openai_compatible_requires_url() -> None:
|
||||||
@@ -34,7 +51,8 @@ def test_openai_compatible_client() -> None:
|
|||||||
url="https://example.com/v1",
|
url="https://example.com/v1",
|
||||||
)
|
)
|
||||||
client = create_client(provider)
|
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"
|
assert provider_to_openai_config(provider).base_url == "https://example.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from plyngent.tools.process import close_pty, open_pty, read_pty, run_command, write_pty
|
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)
|
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:
|
async def test_run_command_echo(workspace: object) -> None:
|
||||||
del workspace
|
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 "exit_code=0" in out
|
||||||
assert "hi" in out
|
assert "hi" in out
|
||||||
|
|
||||||
@@ -44,13 +50,13 @@ async def test_run_command_cwd(workspace: object) -> None:
|
|||||||
sub = workspace / "sub"
|
sub = workspace / "sub"
|
||||||
sub.mkdir()
|
sub.mkdir()
|
||||||
_ = (sub / "f.txt").write_text("z", encoding="utf-8")
|
_ = (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
|
assert "f.txt" in out
|
||||||
|
|
||||||
|
|
||||||
async def test_run_command_timeout(workspace: object) -> None:
|
async def test_run_command_timeout(workspace: object) -> None:
|
||||||
del workspace
|
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
|
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.
|
# Print then sleep past the timeout so communicate has partial stdout after kill.
|
||||||
out = await call_async(
|
out = await call_async(
|
||||||
run_command,
|
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,
|
timeout_seconds=0.3,
|
||||||
)
|
)
|
||||||
assert "timed_out=true" in out
|
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:
|
async def test_run_command_stdin(workspace: object) -> None:
|
||||||
del workspace
|
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 "exit_code=0" in out
|
||||||
assert "hello-stdin" in out
|
assert "hello-stdin" in out
|
||||||
|
|
||||||
@@ -77,7 +87,7 @@ async def test_run_command_env(workspace: object) -> None:
|
|||||||
del workspace
|
del workspace
|
||||||
out = await call_async(
|
out = await call_async(
|
||||||
run_command,
|
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"},
|
env={"PLYNGENT_TEST_VAR": "from-env"},
|
||||||
)
|
)
|
||||||
assert "exit_code=0" in out
|
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:
|
async def test_pty_open_read_close(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
try:
|
try:
|
||||||
opened = call_sync(open_pty, ["sleep", "30"])
|
opened = call_sync(open_pty, _py("import time; time.sleep(30)"))
|
||||||
assert "session_id=" in opened
|
assert "session_id=" in opened
|
||||||
session_id = _session_id(opened)
|
session_id = _session_id(opened)
|
||||||
data = await call_async(read_pty, session_id, timeout=0.05)
|
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:
|
async def test_pty_echo_output(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
try:
|
try:
|
||||||
opened = call_sync(open_pty, ["/bin/echo", "hello-pty"])
|
opened = call_sync(open_pty, _py("print('hello-pty')"))
|
||||||
session_id = _session_id(opened)
|
session_id = _session_id(opened)
|
||||||
text = await call_async(read_pty, session_id, timeout=2.0, until="hello-pty")
|
text = await call_async(read_pty, session_id, timeout=2.0, until="hello-pty")
|
||||||
assert "hello-pty" in text
|
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:
|
async def test_write_pty(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
try:
|
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)
|
session_id = _session_id(opened)
|
||||||
written = call_sync(write_pty, session_id, "pty-input\n")
|
written = call_sync(write_pty, session_id, "pty-input\n")
|
||||||
assert "wrote=" in written
|
assert "wrote=" in written
|
||||||
@@ -143,6 +165,10 @@ async def test_pty_exec_failure_surfaces(workspace: object) -> None:
|
|||||||
del workspace
|
del workspace
|
||||||
try:
|
try:
|
||||||
opened = call_sync(open_pty, ["definitely-not-a-real-binary-xyz"])
|
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)
|
session_id = _session_id(opened)
|
||||||
text = await call_async(read_pty, session_id, timeout=2.0)
|
text = await call_async(read_pty, session_id, timeout=2.0)
|
||||||
# marker and/or dead process with 127
|
# marker and/or dead process with 127
|
||||||
@@ -161,9 +187,9 @@ def test_pty_session_limit(workspace: object) -> None:
|
|||||||
try:
|
try:
|
||||||
PtyManager.set_limit_continue_hook(None)
|
PtyManager.set_limit_continue_hook(None)
|
||||||
PtyManager.configure(max_sessions=1)
|
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
|
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
|
assert "limit" in second
|
||||||
finally:
|
finally:
|
||||||
PtyManager.close_all()
|
PtyManager.close_all()
|
||||||
@@ -177,8 +203,8 @@ def test_pty_session_limit_continue(workspace: object) -> None:
|
|||||||
try:
|
try:
|
||||||
PtyManager.configure(max_sessions=1)
|
PtyManager.configure(max_sessions=1)
|
||||||
PtyManager.set_limit_continue_hook(lambda _reason: True)
|
PtyManager.set_limit_continue_hook(lambda _reason: True)
|
||||||
first = call_sync(open_pty, ["sleep", "30"])
|
first = call_sync(open_pty, _py("import time; time.sleep(30)"))
|
||||||
second = call_sync(open_pty, ["sleep", "30"])
|
second = call_sync(open_pty, _py("import time; time.sleep(30)"))
|
||||||
assert "session_id=" in first
|
assert "session_id=" in first
|
||||||
assert "session_id=" in second
|
assert "session_id=" in second
|
||||||
assert PtyManager.max_sessions >= 2
|
assert PtyManager.max_sessions >= 2
|
||||||
@@ -194,7 +220,7 @@ async def test_pty_output_budget(workspace: object) -> None:
|
|||||||
try:
|
try:
|
||||||
PtyManager.set_limit_continue_hook(None)
|
PtyManager.set_limit_continue_hook(None)
|
||||||
PtyManager.configure(session_output_budget=64)
|
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)
|
session_id = _session_id(opened)
|
||||||
# Drain until budget exhausted or process ends.
|
# Drain until budget exhausted or process ends.
|
||||||
budget_hit = False
|
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)
|
PtyManager.configure(session_output_budget=1024)
|
||||||
class_budget = PtyManager.session_output_budget
|
class_budget = PtyManager.session_output_budget
|
||||||
PtyManager.set_limit_continue_hook(lambda _reason: True)
|
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_id = _session_id(opened)
|
||||||
session = PtyManager.get(session_id)
|
session = PtyManager.get(session_id)
|
||||||
assert session is not None
|
assert session is not None
|
||||||
@@ -247,11 +273,23 @@ def test_pty_master_not_inheritable(workspace: object) -> None:
|
|||||||
del workspace
|
del workspace
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.skip("master FD inheritance is POSIX-only")
|
||||||
|
|
||||||
try:
|
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_id = _session_id(opened)
|
||||||
session = PtyManager.get(session_id)
|
session = PtyManager.get(session_id)
|
||||||
assert session is not None
|
assert session is not None
|
||||||
|
assert session.master_fd is not None
|
||||||
assert os.get_inheritable(session.master_fd) is False
|
assert os.get_inheritable(session.master_fd) is False
|
||||||
finally:
|
finally:
|
||||||
PtyManager.close_all()
|
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