playmaker-cli 0.7.0__tar.gz → 0.7.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/CHANGELOG.md +73 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/PKG-INFO +14 -2
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/README.md +12 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/pyproject.toml +1 -1
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/skills/playmaker-coach/SKILL.md +5 -5
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/agy.py +13 -6
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/claude.py +9 -4
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/codex.py +4 -4
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/gemini.py +5 -4
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/opencode.py +8 -5
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/cli.py +33 -5
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/config.py +19 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/quotas.py +86 -49
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_agy.py +28 -0
- playmaker_cli-0.7.2/tests/test_binary.py +208 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_quotas_antigravity.py +109 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_quotas_zai.py +53 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/.gitignore +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/LICENSE +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/__init__.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/__main__.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/__init__.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/base.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/notify.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/registry.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/state.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/watcher.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/__init__.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_batch.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_claude.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_codex.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_opencode.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_permissions.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_registry.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_skill.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_state.py +0 -0
|
@@ -5,6 +5,79 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.7.2] - 2026-08-18
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`[agents.<name>] binary` was documented, written into every config `init`
|
|
13
|
+
produced, and read by nothing.** All five handlers hardcoded the executable —
|
|
14
|
+
`shutil.which("opencode")` for the availability check, `cmd = ["opencode", …]`
|
|
15
|
+
for the dispatch — so a config pointing at an absolute path or an alternate
|
|
16
|
+
build changed exactly nothing, and the agent still had to be on `PATH` under
|
|
17
|
+
its own name. That is the wrong assumption for how these CLIs install:
|
|
18
|
+
opencode lands in `~/.opencode/bin`, which reaches `PATH` only through a line
|
|
19
|
+
in an interactive `.zshrc`, so every non-interactive dispatch — cron, an
|
|
20
|
+
editor-spawned run, the coach itself — reported the agent unavailable unless
|
|
21
|
+
you'd worked around it with a symlink. The setting is now honoured everywhere
|
|
22
|
+
an executable is named: `is_available()`, both dispatch and resume, and the
|
|
23
|
+
roster probes (`agy models`, `opencode models`, `gemini --list-sessions`). A
|
|
24
|
+
bare name still resolves on `PATH`; an absolute path is used as-is and a
|
|
25
|
+
leading `~` is expanded, since subprocess does no shell expansion of its own.
|
|
26
|
+
When the lookup fails, the error now names the executable it actually tried
|
|
27
|
+
and where the setting came from, instead of insisting it is "not on PATH".
|
|
28
|
+
|
|
29
|
+
- **The Z.ai windows lost their names when z.ai renamed them.** `playmaker
|
|
30
|
+
quotas` had started printing the GLM rows as `5 hours` and `1 week` instead
|
|
31
|
+
of `Session` and `Weekly`: the plans moved to weekly Credits and the API now
|
|
32
|
+
types the inference windows `CREDIT_LIMIT` where it sent `TOKENS_LIMIT` in
|
|
33
|
+
July, so the label lookup missed and fell back to rendering the bare span.
|
|
34
|
+
The labels are shared with the claude probe on purpose — the two providers
|
|
35
|
+
are meant to read like-for-like down the table — so the fallback quietly cost
|
|
36
|
+
the comparison. Both spellings are mapped now. The monthly `MCP tools` pool
|
|
37
|
+
is simply absent from the response on a current plan; nothing to do, it just
|
|
38
|
+
stops appearing.
|
|
39
|
+
|
|
40
|
+
- **The coach skill named a stale GLM model and lied about where the opencode
|
|
41
|
+
default lives.** The skill ships in the wheel, so its facts are the coach's
|
|
42
|
+
facts. It routed bulk work to `zai-coding-plan/glm-5.2` when the plan's
|
|
43
|
+
current flagship is `glm-5.3`, described the Z.ai quota as token windows plus
|
|
44
|
+
a monthly `MCP tools` pool (the windows are credits now; the pool is gone),
|
|
45
|
+
and told the coach that omitting `--model` falls back to whatever
|
|
46
|
+
`~/.config/opencode/opencode.json` names. That last one sends you looking in
|
|
47
|
+
the wrong file: opencode keeps the interactively-picked default in its own
|
|
48
|
+
state, not in that config, so a machine with no `model` key there still
|
|
49
|
+
resolves to something — and the only way to see what an unqualified dispatch
|
|
50
|
+
will run is the `providerID`/`modelID` on a past session in `opencode.db`.
|
|
51
|
+
|
|
52
|
+
- **`--model` for agy validated against the wrong column.** agy 1.1.14 prints
|
|
53
|
+
`agy models` as `<slug>\t<Display Name>` after a `Fetching available
|
|
54
|
+
models...` line; the roster was read as whole lines, so every real slug was
|
|
55
|
+
rejected as unknown. Only the first token counts now (older builds printed
|
|
56
|
+
the bare slug — same first token).
|
|
57
|
+
|
|
58
|
+
## [0.7.1] - 2026-08-18
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **The Antigravity quota probe knocked on every port on the machine, and one
|
|
63
|
+
of them answered in TLS.** `playmaker quotas` had shown
|
|
64
|
+
`Antigravity (agy) error: BadStatusLine` for a week: the daemon lookup ran
|
|
65
|
+
`lsof -p <pid> -iTCP -sTCP:LISTEN` without `-a`, and lsof ORs its selectors
|
|
66
|
+
unless told otherwise — so "agy's listening sockets" was actually every
|
|
67
|
+
LISTEN socket on the box (hence the old `!= 5432` Postgres carve-out). The
|
|
68
|
+
probe then POSTed the quota RPC to Steam, chromedriver, a Logi plugin, …
|
|
69
|
+
until a TLS-only listener sorted below agy's ports answered a plaintext
|
|
70
|
+
request with a TLS alert record. urllib raises that as
|
|
71
|
+
`http.client.BadStatusLine`, which is not an `OSError`, so it escaped the
|
|
72
|
+
per-port `except`, escaped `antigravity_probe`, and the aggregator recorded
|
|
73
|
+
the whole provider as an error — with the working local daemon two ports
|
|
74
|
+
away. lsof now gets `-a` (one call, all pids), the `pgrep` for `agy` is
|
|
75
|
+
anchored so playmaker's own dispatches with `playmaker-agy-*.log` in their
|
|
76
|
+
arguments don't count as the daemon, anything a port says only disqualifies
|
|
77
|
+
that port, and any failure on the local path falls back to the remote
|
|
78
|
+
Gemini-only probe rather than to `error`. The refresh also stopped spending
|
|
79
|
+
~30 s in timeouts on strangers' ports (1.3 s now).
|
|
80
|
+
|
|
8
81
|
## [0.7.0] - 2026-08-05
|
|
9
82
|
|
|
10
83
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: playmaker-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity and opencode sub-agents in parallel.
|
|
5
5
|
Project-URL: Homepage, https://github.com/vladsafedev/playmaker
|
|
6
6
|
Project-URL: Repository, https://github.com/vladsafedev/playmaker
|
|
@@ -309,6 +309,18 @@ reads `process.env.PWD`, which a subprocess `cwd` does not update, so left
|
|
|
309
309
|
alone it would ignore `--cwd` and write into the directory *you* were standing
|
|
310
310
|
in. playmaker passes `--dir` and fixes up `PWD`, so `--cwd` means what it says.
|
|
311
311
|
|
|
312
|
+
And it is the agent most likely to need `binary`, which every lane accepts:
|
|
313
|
+
|
|
314
|
+
```toml
|
|
315
|
+
[agents.opencode]
|
|
316
|
+
binary = "~/.opencode/bin/opencode"
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
A bare name is resolved on `PATH`; a path is used as-is. opencode installs to
|
|
320
|
+
`~/.opencode/bin`, which reaches `PATH` only via a line in an interactive
|
|
321
|
+
`.zshrc` — so a dispatch from cron, an editor, or the coach can't find it
|
|
322
|
+
otherwise, and playmaker would report the agent as unavailable.
|
|
323
|
+
|
|
312
324
|
## Notifications
|
|
313
325
|
|
|
314
326
|
Every detached dispatch pings when it finishes. With
|
|
@@ -282,6 +282,18 @@ reads `process.env.PWD`, which a subprocess `cwd` does not update, so left
|
|
|
282
282
|
alone it would ignore `--cwd` and write into the directory *you* were standing
|
|
283
283
|
in. playmaker passes `--dir` and fixes up `PWD`, so `--cwd` means what it says.
|
|
284
284
|
|
|
285
|
+
And it is the agent most likely to need `binary`, which every lane accepts:
|
|
286
|
+
|
|
287
|
+
```toml
|
|
288
|
+
[agents.opencode]
|
|
289
|
+
binary = "~/.opencode/bin/opencode"
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
A bare name is resolved on `PATH`; a path is used as-is. opencode installs to
|
|
293
|
+
`~/.opencode/bin`, which reaches `PATH` only via a line in an interactive
|
|
294
|
+
`.zshrc` — so a dispatch from cron, an editor, or the coach can't find it
|
|
295
|
+
otherwise, and playmaker would report the agent as unavailable.
|
|
296
|
+
|
|
285
297
|
## Notifications
|
|
286
298
|
|
|
287
299
|
Every detached dispatch pings when it finishes. With
|
|
@@ -23,7 +23,7 @@ All Claude work — coach, internal sub-agents, external `claude -p` — draws f
|
|
|
23
23
|
|
|
24
24
|
3. **External dispatch — `playmaker dispatch <agent>`.** Separate OS processes, tracked in playmaker (`list`/`watch`/`thread`/`continue`):
|
|
25
25
|
- **`claude -p` (sibling Claude):** same subscription. Its key lever is the model bucket — **Sonnet is a separate weekly bucket from Opus**, usually idle while Opus depletes, so default **`--model sonnet`** to spare the scarce Opus bucket (**`--model haiku`** for trivial mechanical work). It **can write files** — playmaker runs it in `acceptEdits`, so it edits and runs commands freely inside `--cwd` and is refused outside it (see §10). Use it over an internal sub-agent when you want a **tracked, detached work-stream** you can monitor/continue independently of the coach's turn.
|
|
26
|
-
- **`codex` / `agy` / `opencode`:** each on its own subscription/quota — the right home for **write-heavy** parallel implementation that can leave the Anthropic subscription. `agy` is special: besides Gemini tiers it carries **Claude Sonnet/Opus 4.6 (Thinking)** on Google's pool, so even "must be Claude-quality" work can leave the Anthropic quota. `opencode` is the widest lane: one CLI over ~75 providers, addressed as `provider/model` — a GLM coding plan (`zai-coding-plan/glm-5.
|
|
26
|
+
- **`codex` / `agy` / `opencode`:** each on its own subscription/quota — the right home for **write-heavy** parallel implementation that can leave the Anthropic subscription. `agy` is special: besides Gemini tiers it carries **Claude Sonnet/Opus 4.6 (Thinking)** on Google's pool, so even "must be Claude-quality" work can leave the Anthropic quota. `opencode` is the widest lane: one CLI over ~75 providers, addressed as `provider/model` — a GLM coding plan (`zai-coding-plan/glm-5.3`), or a model running locally on this machine, which spends **no** subscription quota at all.
|
|
27
27
|
|
|
28
28
|
**Routing cheat-sheet for Claude-side work:**
|
|
29
29
|
|
|
@@ -34,7 +34,7 @@ All Claude work — coach, internal sub-agents, external `claude -p` — draws f
|
|
|
34
34
|
| is heavy reasoning only the coach can do | **coach** | top-tier Opus, serial |
|
|
35
35
|
| is write-heavy and can leave Claude | **codex / agy / opencode** | their own quotas |
|
|
36
36
|
| needs top-tier Claude but the Anthropic Opus weekly is precious | **`dispatch agy --model claude-opus-4-6-thinking`** | Opus quality on Google's pool |
|
|
37
|
-
| is bulk work and every subscription is running low | **`dispatch opencode --model zai-coding-plan/glm-5.
|
|
37
|
+
| is bulk work and every subscription is running low | **`dispatch opencode --model zai-coding-plan/glm-5.3`** | a separate GLM plan, untouched by the others |
|
|
38
38
|
| is mechanical and privacy-sensitive, or all quotas are spent | **`dispatch opencode --model <local provider>/<model>`** | runs on this machine; costs no quota, just wall-clock |
|
|
39
39
|
|
|
40
40
|
**Sonnet is your cheap parallel Claude worker** — a separate weekly bucket that usually sits idle while Opus depletes. Reach for it (via `dispatch claude --model sonnet`, or by pointing an internal sub-agent at Sonnet) instead of burning Opus on mid-tier work.
|
|
@@ -63,7 +63,7 @@ playmaker quotas --refresh # current capacity, broken out per model
|
|
|
63
63
|
- Claude: two non-coach ways to run it — an **internal sub-agent** (Task tool; in-session, write-capable, result returns to the coach) and an **external `claude -p` dispatch** (tracked, detached stream). Both draw on the subscription; the difference is where results land, not cost. See "Execution lanes". Default external Claude to `--model sonnet` — its weekly bucket is separate from Opus and usually idle, so it spares the scarce coach (Opus) bucket.
|
|
64
64
|
- Antigravity (`agy`): one Google pool split across model families — Gemini Flash tiers for cheap bulk work, Gemini 3.1 Pro for hard Gemini work, **Claude Sonnet/Opus 4.6 (Thinking)** as Anthropic-quality capacity that spends *Google's* quota, GPT-OSS 120B as a spare mid-tier. `playmaker quotas` shows the **full categorized breakdown** — `Gemini 5h` / `Gemini weekly` and `Claude/GPT 5h` / `Claude/GPT weekly`. Two things share a bucket: all Gemini models draw the Gemini bucket, and Claude *and* GPT-OSS share the Claude/GPT bucket. So dispatching Opus 4.6 via agy spends the same `Claude/GPT` bucket as Sonnet or GPT-OSS — watch the `Claude/GPT 5h` window when fanning out several agy-Claude jobs. (This needs agy's local daemon up — normally true when any agy process is running; if `playmaker quotas` tags agy "daemon offline" it fell back to a coarse Gemini-only view.)
|
|
65
65
|
- Codex: top-tier vs lighter modes (where applicable).
|
|
66
|
-
- Z.ai (GLM): shown as its own provider because the plan is what has the quota, not the CLI — `Session` (5h) and `Weekly` windows
|
|
66
|
+
- Z.ai (GLM): shown as its own provider because the plan is what has the quota, not the CLI — `Session` (5h) and `Weekly` windows, denominated in the plan's **credits** (Pro: 12k per session, 60k per week). It appears whenever `opencode auth login` has a Z.AI credential, and reads *unsupported* when it doesn't. An `opencode` dispatch pointed at a **local** model spends none of it, so local lanes never show up in this table at all.
|
|
67
67
|
|
|
68
68
|
If `quotas.json` is more than 1h old (or shows errors), say so before relying on the numbers.
|
|
69
69
|
|
|
@@ -162,7 +162,7 @@ Two exceptions to "always pass `--model`":
|
|
|
162
162
|
- **codex** — its model roster depends on the account plan, and an unavailable name fails the whole dispatch (`codex turn failed: … not supported when using Codex with a ChatGPT account`). Omitting `--model` uses whatever that account actually has, which is usually what you want.
|
|
163
163
|
- **agy** — its own default is a top-tier model, so omitting `--model` on a dispatch meant to be cheap silently spends the expensive bucket. Always pass it here.
|
|
164
164
|
|
|
165
|
-
**opencode is `provider/model`, and the default is
|
|
165
|
+
**opencode is `provider/model`, and the default is invisible.** Names look like `zai-coding-plan/glm-5.3` or `lmstudio/qwen/qwen3-coder-30b` — **run `opencode models` and copy a line** rather than writing one from memory; playmaker validates against that roster and fails the dispatch on a name it doesn't contain. Omitting `--model` falls through to opencode's own default, which is whatever the user last picked interactively — and it is *not* necessarily written to `~/.config/opencode/opencode.json`, so you cannot read it back from a config file. The only way to know what an unqualified dispatch will run is to look at the `providerID`/`modelID` on a past session in `opencode.db`. So for opencode, always pass `--model` unless `[agents.opencode] model` is set in `~/.playmaker/config.toml` — which is the cheap fix: pin it there once and the lane becomes deterministic.
|
|
166
166
|
|
|
167
167
|
**agy prompt discipline:** the agy agent's shell lives in a private scratch directory, not the workspace. playmaker automatically prepends a workspace preamble to every agy dispatch, but reinforce it: word file instructions with paths relative to the workspace root or absolute paths, never "in the current directory".
|
|
168
168
|
|
|
@@ -274,7 +274,7 @@ playmaker watch # Rich live TUI of sessions
|
|
|
274
274
|
|
|
275
275
|
Every command takes `--json` for machine-readable output.
|
|
276
276
|
|
|
277
|
-
`--model NAME` is forwarded to the agent's native CLI: `claude --model sonnet`, `agy --model claude-opus-4-6-thinking`, `codex -m <whatever that account has>`, `opencode -m zai-coding-plan/glm-5.
|
|
277
|
+
`--model NAME` is forwarded to the agent's native CLI: `claude --model sonnet`, `agy --model claude-opus-4-6-thinking`, `codex -m <whatever that account has>`, `opencode -m zai-coding-plan/glm-5.3`. Without it the agent CLI uses its own default. Model is stored on the session row, so detached re-runs and `continue` inherit it; `continue --model X` overrides for that one turn.
|
|
278
278
|
|
|
279
279
|
**The agy roster is not documented here on purpose** — it changes with Antigravity releases, and so does the spelling convention. Run `agy models` and copy a line. At the time of writing it returns bare slugs in the shape `gemini-3.6-flash-{low,medium,high}`, `gemini-3.1-pro-{low,high}`, `claude-sonnet-4-6`, `claude-opus-4-6-thinking`, `gpt-oss-120b-medium` — but treat that as an example of the *shape*, not a list to copy from.
|
|
280
280
|
|
|
@@ -37,7 +37,7 @@ import time
|
|
|
37
37
|
from pathlib import Path
|
|
38
38
|
|
|
39
39
|
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
40
|
-
from playmaker.config import agent_setting, yolo_enabled
|
|
40
|
+
from playmaker.config import agent_binary, agent_setting, yolo_enabled
|
|
41
41
|
|
|
42
42
|
AGY_BRAIN_ROOT = Path("~/.gemini/antigravity-cli/brain").expanduser()
|
|
43
43
|
|
|
@@ -51,7 +51,7 @@ class AgyHandler:
|
|
|
51
51
|
name = "agy"
|
|
52
52
|
|
|
53
53
|
def is_available(self) -> bool:
|
|
54
|
-
return shutil.which("agy") is not None
|
|
54
|
+
return shutil.which(agent_binary("agy")) is not None
|
|
55
55
|
|
|
56
56
|
@staticmethod
|
|
57
57
|
@functools.lru_cache(maxsize=1)
|
|
@@ -63,18 +63,25 @@ class AgyHandler:
|
|
|
63
63
|
Cached per-process; returns () if the roster can't be read (then we skip
|
|
64
64
|
validation rather than block a dispatch on a probe failure).
|
|
65
65
|
"""
|
|
66
|
-
if shutil.which("agy") is None:
|
|
66
|
+
if shutil.which(agent_binary("agy")) is None:
|
|
67
67
|
return ()
|
|
68
68
|
try:
|
|
69
69
|
proc = subprocess.run(
|
|
70
|
-
["agy", "models"],
|
|
70
|
+
[agent_binary("agy"), "models"],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
timeout=15,
|
|
71
74
|
)
|
|
72
75
|
except (OSError, subprocess.SubprocessError):
|
|
73
76
|
return ()
|
|
74
77
|
if proc.returncode != 0:
|
|
75
78
|
return ()
|
|
79
|
+
# `agy models` output is columnar: "<slug>\t<Display Name>" (older
|
|
80
|
+
# releases printed the bare slug only) — the slug is the first token.
|
|
76
81
|
return tuple(
|
|
77
|
-
line.
|
|
82
|
+
line.split()[0]
|
|
83
|
+
for line in proc.stdout.splitlines()
|
|
84
|
+
if line.strip() and not line.startswith("Fetching")
|
|
78
85
|
)
|
|
79
86
|
|
|
80
87
|
def _validate_model(self, model: str | None) -> None:
|
|
@@ -146,7 +153,7 @@ class AgyHandler:
|
|
|
146
153
|
os.close(fd_log)
|
|
147
154
|
log_path = Path(log_name)
|
|
148
155
|
|
|
149
|
-
cmd = ["agy", "-p", full_prompt, "--log-file", str(log_path)]
|
|
156
|
+
cmd = [agent_binary("agy"), "-p", full_prompt, "--log-file", str(log_path)]
|
|
150
157
|
if conversation_id:
|
|
151
158
|
cmd += ["--conversation", conversation_id]
|
|
152
159
|
# agy has no middle tier: unlike claude there is no per-mode permission
|
|
@@ -17,7 +17,12 @@ import subprocess
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
20
|
-
from playmaker.config import
|
|
20
|
+
from playmaker.config import (
|
|
21
|
+
agent_binary,
|
|
22
|
+
agent_list_setting,
|
|
23
|
+
agent_setting,
|
|
24
|
+
yolo_enabled,
|
|
25
|
+
)
|
|
21
26
|
|
|
22
27
|
# What a sub-agent is allowed to do without a human at the keyboard.
|
|
23
28
|
# Verified against claude 2.x in `-p` mode:
|
|
@@ -53,7 +58,7 @@ class ClaudeHandler:
|
|
|
53
58
|
name = "claude"
|
|
54
59
|
|
|
55
60
|
def is_available(self) -> bool:
|
|
56
|
-
return shutil.which("claude") is not None
|
|
61
|
+
return shutil.which(agent_binary("claude")) is not None
|
|
57
62
|
|
|
58
63
|
def dispatch(
|
|
59
64
|
self,
|
|
@@ -76,7 +81,7 @@ class ClaudeHandler:
|
|
|
76
81
|
# `--verbose` is required by claude-cli when stream-json is used
|
|
77
82
|
# without partial-messages; without it we get a parse-time refusal.
|
|
78
83
|
cmd = [
|
|
79
|
-
"claude",
|
|
84
|
+
agent_binary("claude"),
|
|
80
85
|
"-p",
|
|
81
86
|
"--output-format",
|
|
82
87
|
"stream-json",
|
|
@@ -185,7 +190,7 @@ class ClaudeHandler:
|
|
|
185
190
|
) -> DispatchResult:
|
|
186
191
|
full_prompt = self._build_prompt(prompt, files or [])
|
|
187
192
|
cmd = [
|
|
188
|
-
"claude",
|
|
193
|
+
agent_binary("claude"),
|
|
189
194
|
"-p",
|
|
190
195
|
"--resume",
|
|
191
196
|
agent_session_id,
|
|
@@ -23,7 +23,7 @@ import time
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
|
|
25
25
|
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
26
|
-
from playmaker.config import agent_setting
|
|
26
|
+
from playmaker.config import agent_binary, agent_setting
|
|
27
27
|
|
|
28
28
|
CODEX_SESSIONS_ROOT = Path("~/.codex/sessions").expanduser()
|
|
29
29
|
|
|
@@ -44,7 +44,7 @@ class CodexHandler:
|
|
|
44
44
|
name = "codex"
|
|
45
45
|
|
|
46
46
|
def is_available(self) -> bool:
|
|
47
|
-
return shutil.which("codex") is not None
|
|
47
|
+
return shutil.which(agent_binary("codex")) is not None
|
|
48
48
|
|
|
49
49
|
def dispatch(
|
|
50
50
|
self,
|
|
@@ -62,7 +62,7 @@ class CodexHandler:
|
|
|
62
62
|
last_msg_path = Path(tmp.name)
|
|
63
63
|
|
|
64
64
|
cmd = [
|
|
65
|
-
"codex",
|
|
65
|
+
agent_binary("codex"),
|
|
66
66
|
"exec",
|
|
67
67
|
"--json",
|
|
68
68
|
"--skip-git-repo-check",
|
|
@@ -226,7 +226,7 @@ class CodexHandler:
|
|
|
226
226
|
|
|
227
227
|
# `codex exec resume` has no --cd; cwd flows through subprocess.
|
|
228
228
|
cmd = [
|
|
229
|
-
"codex",
|
|
229
|
+
agent_binary("codex"),
|
|
230
230
|
"exec",
|
|
231
231
|
"resume",
|
|
232
232
|
"--json",
|
|
@@ -21,6 +21,7 @@ import time
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
23
|
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
24
|
+
from playmaker.config import agent_binary
|
|
24
25
|
|
|
25
26
|
GEMINI_CHATS_ROOT = Path("~/.gemini/tmp").expanduser()
|
|
26
27
|
|
|
@@ -29,7 +30,7 @@ class GeminiHandler:
|
|
|
29
30
|
name = "gemini"
|
|
30
31
|
|
|
31
32
|
def is_available(self) -> bool:
|
|
32
|
-
return shutil.which("gemini") is not None
|
|
33
|
+
return shutil.which(agent_binary("gemini")) is not None
|
|
33
34
|
|
|
34
35
|
def dispatch(
|
|
35
36
|
self,
|
|
@@ -48,7 +49,7 @@ class GeminiHandler:
|
|
|
48
49
|
"""
|
|
49
50
|
full_prompt = self._build_prompt(prompt, files or [])
|
|
50
51
|
cmd = [
|
|
51
|
-
"gemini",
|
|
52
|
+
agent_binary("gemini"),
|
|
52
53
|
"-p",
|
|
53
54
|
full_prompt,
|
|
54
55
|
"-o",
|
|
@@ -150,7 +151,7 @@ class GeminiHandler:
|
|
|
150
151
|
|
|
151
152
|
full_prompt = self._build_prompt(prompt, files or [])
|
|
152
153
|
cmd = [
|
|
153
|
-
"gemini",
|
|
154
|
+
agent_binary("gemini"),
|
|
154
155
|
"--resume",
|
|
155
156
|
str(index),
|
|
156
157
|
"-p",
|
|
@@ -220,7 +221,7 @@ class GeminiHandler:
|
|
|
220
221
|
" <N>. <title> (<age>) [<uuid>]"
|
|
221
222
|
"""
|
|
222
223
|
proc = subprocess.run(
|
|
223
|
-
["gemini", "--list-sessions"],
|
|
224
|
+
[agent_binary("gemini"), "--list-sessions"],
|
|
224
225
|
cwd=str(cwd),
|
|
225
226
|
capture_output=True,
|
|
226
227
|
text=True,
|
|
@@ -36,7 +36,7 @@ from datetime import UTC, datetime
|
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
|
|
38
38
|
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
39
|
-
from playmaker.config import agent_setting, yolo_enabled
|
|
39
|
+
from playmaker.config import agent_binary, agent_setting, yolo_enabled
|
|
40
40
|
from playmaker.state import PLAYMAKER_HOME
|
|
41
41
|
|
|
42
42
|
|
|
@@ -55,7 +55,7 @@ class OpencodeHandler:
|
|
|
55
55
|
name = "opencode"
|
|
56
56
|
|
|
57
57
|
def is_available(self) -> bool:
|
|
58
|
-
return shutil.which("opencode") is not None
|
|
58
|
+
return shutil.which(agent_binary("opencode")) is not None
|
|
59
59
|
|
|
60
60
|
@staticmethod
|
|
61
61
|
@functools.lru_cache(maxsize=1)
|
|
@@ -67,11 +67,14 @@ class OpencodeHandler:
|
|
|
67
67
|
per-process; () if the roster can't be read (then validation is skipped
|
|
68
68
|
rather than blocking a dispatch on a probe failure).
|
|
69
69
|
"""
|
|
70
|
-
if shutil.which("opencode") is None:
|
|
70
|
+
if shutil.which(agent_binary("opencode")) is None:
|
|
71
71
|
return ()
|
|
72
72
|
try:
|
|
73
73
|
proc = subprocess.run(
|
|
74
|
-
["opencode", "models"],
|
|
74
|
+
[agent_binary("opencode"), "models"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
timeout=15,
|
|
75
78
|
)
|
|
76
79
|
except (OSError, subprocess.SubprocessError):
|
|
77
80
|
return ()
|
|
@@ -157,7 +160,7 @@ class OpencodeHandler:
|
|
|
157
160
|
# directory from process.env.PWD, which Popen(cwd=…) leaves pointing at
|
|
158
161
|
# the parent. Without it a dispatch writes into whatever directory the
|
|
159
162
|
# coach happened to be in. We set both and let them agree.
|
|
160
|
-
cmd = ["opencode", "run", "--format", "json", "--dir", str(cwd)]
|
|
163
|
+
cmd = [agent_binary("opencode"), "run", "--format", "json", "--dir", str(cwd)]
|
|
161
164
|
if session_id:
|
|
162
165
|
cmd += ["-s", session_id]
|
|
163
166
|
if effective_model:
|
|
@@ -158,7 +158,7 @@ def dispatch(
|
|
|
158
158
|
state.init_db()
|
|
159
159
|
handler = get_handler(agent)
|
|
160
160
|
if not handler.is_available():
|
|
161
|
-
err_console.print(
|
|
161
|
+
err_console.print(_unavailable(agent))
|
|
162
162
|
raise typer.Exit(1)
|
|
163
163
|
|
|
164
164
|
cwd_resolved = cwd.expanduser().resolve()
|
|
@@ -414,7 +414,7 @@ def continue_(
|
|
|
414
414
|
|
|
415
415
|
handler = get_handler(parent["agent"])
|
|
416
416
|
if not handler.is_available():
|
|
417
|
-
err_console.print(
|
|
417
|
+
err_console.print(_unavailable(parent["agent"]))
|
|
418
418
|
raise typer.Exit(1)
|
|
419
419
|
|
|
420
420
|
cwd_resolved = (cwd or Path(parent["cwd"])).expanduser().resolve()
|
|
@@ -845,10 +845,13 @@ def _render_provider(name: str, info: dict) -> None:
|
|
|
845
845
|
"agy": "green",
|
|
846
846
|
"gemini": "cyan",
|
|
847
847
|
"zai": "yellow",
|
|
848
|
+
"ollama": "bright_cyan",
|
|
848
849
|
}.get(name, "white")
|
|
849
|
-
display = {
|
|
850
|
-
|
|
851
|
-
|
|
850
|
+
display = {
|
|
851
|
+
"agy": "Antigravity (agy)",
|
|
852
|
+
"zai": "Z.ai (GLM, via opencode)",
|
|
853
|
+
"ollama": "Ollama (local, via opencode)",
|
|
854
|
+
}.get(name, name.capitalize())
|
|
852
855
|
title = f"[bold {label_color}]{display}[/bold {label_color}]"
|
|
853
856
|
suffix_parts: list[str] = []
|
|
854
857
|
if info.get("account_email"):
|
|
@@ -896,6 +899,12 @@ def _render_provider(name: str, info: dict) -> None:
|
|
|
896
899
|
line += f" [dim]{' · '.join(right_parts)}[/dim]"
|
|
897
900
|
console.print(line)
|
|
898
901
|
|
|
902
|
+
# A local provider has models where the metered ones have tiers; naming them
|
|
903
|
+
# saves the coach a round-trip to `ollama list` before it can route here.
|
|
904
|
+
models = info.get("models")
|
|
905
|
+
if models:
|
|
906
|
+
console.print(f" [dim]models: {', '.join(models)}[/dim]")
|
|
907
|
+
|
|
899
908
|
# Metered overage pool ("Extra usage" in Claude's UI) — the bucket that
|
|
900
909
|
# holds usage-credit / Agent-SDK spend. monthly_limit/used arrive in cents.
|
|
901
910
|
extra = info.get("extra_usage")
|
|
@@ -943,6 +952,19 @@ def agents() -> None:
|
|
|
943
952
|
console.print(table)
|
|
944
953
|
|
|
945
954
|
|
|
955
|
+
def _unavailable(agent: str) -> str:
|
|
956
|
+
"""Why a dispatch is refused, naming the executable we actually looked for.
|
|
957
|
+
|
|
958
|
+
Without the name this reads as "not on PATH" even when the config points
|
|
959
|
+
`binary` at a path that simply does not exist.
|
|
960
|
+
"""
|
|
961
|
+
from playmaker.config import agent_binary
|
|
962
|
+
|
|
963
|
+
executable = agent_binary(agent)
|
|
964
|
+
hint = "" if executable == agent else f" (agents.{agent}.binary in {state.CONFIG_PATH})"
|
|
965
|
+
return f"[red]agent {agent!r}: {executable!r} not found{hint}[/red]"
|
|
966
|
+
|
|
967
|
+
|
|
946
968
|
def _status_icon(status: str) -> str:
|
|
947
969
|
return {
|
|
948
970
|
"pending": "[yellow]pending[/yellow]",
|
|
@@ -965,6 +987,12 @@ editor = "Zed"
|
|
|
965
987
|
|
|
966
988
|
# How much each sub-agent may do while nobody is watching. A detached agent
|
|
967
989
|
# cannot answer a permission prompt, so every agent needs *some* answer here.
|
|
990
|
+
#
|
|
991
|
+
# `binary` is the executable playmaker launches for that agent. A bare name is
|
|
992
|
+
# resolved on PATH; an absolute path (~ allowed) is used as-is. Reach for the
|
|
993
|
+
# path form when the CLI installs somewhere only an interactive shell knows
|
|
994
|
+
# about — opencode's ~/.opencode/bin is added by a line in .zshrc, so a cron or
|
|
995
|
+
# editor-spawned dispatch cannot find it otherwise.
|
|
968
996
|
|
|
969
997
|
[agents.claude]
|
|
970
998
|
binary = "claude"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import tomllib
|
|
6
7
|
from functools import lru_cache
|
|
7
8
|
from typing import Any
|
|
@@ -24,6 +25,24 @@ def agent_setting(agent: str, key: str, default: Any = None) -> Any:
|
|
|
24
25
|
return load_config().get("agents", {}).get(agent, {}).get(key, default)
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
def agent_binary(agent: str) -> str:
|
|
29
|
+
"""The executable to launch for this agent.
|
|
30
|
+
|
|
31
|
+
`[agents.<agent>] binary` when set, else the agent's own name. A bare name
|
|
32
|
+
and an absolute path both work: every call site feeds this to `shutil.which`
|
|
33
|
+
or straight to `subprocess`, and `which` returns a path containing a
|
|
34
|
+
separator as-is. `~` is expanded here because these CLIs install under the
|
|
35
|
+
home directory often enough — opencode lands in ~/.opencode/bin, which only
|
|
36
|
+
an interactive shell puts on PATH, so a non-interactive dispatch (cron, an
|
|
37
|
+
editor-spawned run, the coach) cannot find it without this setting.
|
|
38
|
+
"""
|
|
39
|
+
value = agent_setting(agent, "binary")
|
|
40
|
+
if value is None:
|
|
41
|
+
return agent
|
|
42
|
+
text = os.path.expanduser(str(value).strip())
|
|
43
|
+
return text or agent
|
|
44
|
+
|
|
45
|
+
|
|
27
46
|
def setting(section: str, key: str, default: Any = None) -> Any:
|
|
28
47
|
"""Look up [<section>] <key>, falling back to `default`."""
|
|
29
48
|
value = load_config().get(section, {})
|
|
@@ -612,39 +612,48 @@ def gemini_probe() -> dict:
|
|
|
612
612
|
_ANTIGRAVITY_QUOTA_SUMMARY_PATH = (
|
|
613
613
|
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary"
|
|
614
614
|
)
|
|
615
|
-
|
|
615
|
+
# `pgrep -f` regexes over the full command line. The agy one is anchored so a
|
|
616
|
+
# process whose *arguments* merely mention agy — playmaker's own dispatch, with
|
|
617
|
+
# its `--log-file .../playmaker-agy-*.log` — doesn't count as the daemon.
|
|
618
|
+
_ANTIGRAVITY_PROC_PATTERNS = (r"(^|/)agy( |$)", r"language_server")
|
|
616
619
|
|
|
617
620
|
|
|
618
621
|
def _antigravity_daemon_ports() -> list[int]:
|
|
619
622
|
"""Local TCP ports that a running agy/language_server daemon is listening on."""
|
|
620
623
|
pids: set[int] = set()
|
|
621
|
-
for
|
|
624
|
+
for pattern in _ANTIGRAVITY_PROC_PATTERNS:
|
|
622
625
|
try:
|
|
623
626
|
proc = subprocess.run(
|
|
624
|
-
["pgrep", "-f",
|
|
627
|
+
["pgrep", "-f", pattern], capture_output=True, text=True, timeout=5
|
|
625
628
|
)
|
|
626
629
|
except (OSError, subprocess.SubprocessError):
|
|
627
630
|
continue
|
|
628
631
|
for line in proc.stdout.split():
|
|
629
632
|
if line.isdigit():
|
|
630
633
|
pids.add(int(line))
|
|
634
|
+
if not pids:
|
|
635
|
+
return []
|
|
636
|
+
# `-a` ANDs lsof's selectors. Without it they are ORed, and the listing is
|
|
637
|
+
# every LISTEN socket on the machine — Steam, chromedriver, whatever else
|
|
638
|
+
# sits on 127.0.0.1 — so the probe went knocking on strangers' ports.
|
|
639
|
+
try:
|
|
640
|
+
proc = subprocess.run(
|
|
641
|
+
[
|
|
642
|
+
"lsof", "-nP", "-a",
|
|
643
|
+
"-p", ",".join(str(pid) for pid in sorted(pids)),
|
|
644
|
+
"-iTCP", "-sTCP:LISTEN",
|
|
645
|
+
],
|
|
646
|
+
capture_output=True,
|
|
647
|
+
text=True,
|
|
648
|
+
timeout=5,
|
|
649
|
+
)
|
|
650
|
+
except (OSError, subprocess.SubprocessError):
|
|
651
|
+
return []
|
|
631
652
|
ports: set[int] = set()
|
|
632
|
-
for
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
capture_output=True,
|
|
637
|
-
text=True,
|
|
638
|
-
timeout=5,
|
|
639
|
-
)
|
|
640
|
-
except (OSError, subprocess.SubprocessError):
|
|
641
|
-
continue
|
|
642
|
-
for line in proc.stdout.splitlines():
|
|
643
|
-
m = re.search(r"127\.0\.0\.1:(\d+)", line)
|
|
644
|
-
if m:
|
|
645
|
-
port = int(m.group(1))
|
|
646
|
-
if port != 5432: # skip an unrelated Postgres LISTEN
|
|
647
|
-
ports.add(port)
|
|
653
|
+
for line in proc.stdout.splitlines():
|
|
654
|
+
m = re.search(r"127\.0\.0\.1:(\d+)", line)
|
|
655
|
+
if m:
|
|
656
|
+
ports.add(int(m.group(1)))
|
|
648
657
|
return sorted(ports)
|
|
649
658
|
|
|
650
659
|
|
|
@@ -664,12 +673,19 @@ def _antigravity_local_summary(ports: list[int], timeout: float = 5.0) -> dict |
|
|
|
664
673
|
method="POST",
|
|
665
674
|
headers={"Content-Type": "application/json", "Connect-Protocol-Version": "1"},
|
|
666
675
|
)
|
|
676
|
+
# Whatever a port says, it only ever disqualifies that port. Not
|
|
677
|
+
# every refusal is an OSError: a TLS-only listener answers a
|
|
678
|
+
# plaintext POST with a TLS alert record, which urllib raises as
|
|
679
|
+
# http.client.BadStatusLine — an HTTPException that used to escape
|
|
680
|
+
# this loop and sink the whole probe.
|
|
667
681
|
try:
|
|
668
682
|
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
|
669
683
|
data = json.loads(resp.read().decode("utf-8"))
|
|
670
|
-
|
|
684
|
+
if not isinstance(data, dict):
|
|
685
|
+
continue
|
|
686
|
+
payload = data.get("response") or data.get("summary") or data
|
|
687
|
+
except Exception:
|
|
671
688
|
continue
|
|
672
|
-
payload = data.get("response") or data.get("summary") or data
|
|
673
689
|
if isinstance(payload, dict) and payload.get("groups"):
|
|
674
690
|
return payload
|
|
675
691
|
return None
|
|
@@ -756,29 +772,36 @@ def antigravity_probe() -> dict:
|
|
|
756
772
|
back to the OAuth retrieveUserQuota, which only surfaces coarse Gemini daily
|
|
757
773
|
buckets (source: "remote") — the Claude/GPT windows are simply not available
|
|
758
774
|
to a plain OAuth token. The local path needs a running agy/CodexBar daemon.
|
|
775
|
+
|
|
776
|
+
The local path is a bonus, so it degrades to remote rather than to an
|
|
777
|
+
error: whatever goes wrong there — a stray listener, a daemon mid-start,
|
|
778
|
+
a payload we don't recognise — the user still gets a quota row.
|
|
759
779
|
"""
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
780
|
+
windows: list[dict] = []
|
|
781
|
+
try:
|
|
782
|
+
ports = _antigravity_daemon_ports()
|
|
783
|
+
payload = _antigravity_local_summary(ports) if ports else None
|
|
763
784
|
if payload:
|
|
764
785
|
windows = _antigravity_windows_from_summary(payload)
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
786
|
+
except Exception:
|
|
787
|
+
windows = []
|
|
788
|
+
if windows:
|
|
789
|
+
out = {
|
|
790
|
+
"status": "ok",
|
|
791
|
+
"account_email": None,
|
|
792
|
+
"tier": None,
|
|
793
|
+
"windows": windows,
|
|
794
|
+
"source": "local",
|
|
795
|
+
}
|
|
796
|
+
# Enrich email/tier from the cheap OAuth loadCodeAssist call;
|
|
797
|
+
# never let that failure sink the rich local windows.
|
|
798
|
+
try:
|
|
799
|
+
meta = _antigravity_account_meta()
|
|
800
|
+
out["account_email"] = meta.get("email")
|
|
801
|
+
out["tier"] = meta.get("tier")
|
|
802
|
+
except Exception:
|
|
803
|
+
pass
|
|
804
|
+
return out
|
|
782
805
|
|
|
783
806
|
result = _google_code_assist_probe(_ANTIGRAVITY_CLOUDCODE_BASE, "ANTIGRAVITY")
|
|
784
807
|
result["source"] = "remote"
|
|
@@ -913,7 +936,15 @@ _ZAI_UNIT_NAMES = {3: "hour", 4: "day", 5: "month", 6: "week"}
|
|
|
913
936
|
|
|
914
937
|
# (type, unit, number) -> label. "Session"/"Weekly" deliberately match the
|
|
915
938
|
# claude probe's labels so the two providers read as like-for-like in the table.
|
|
939
|
+
#
|
|
940
|
+
# z.ai renamed the inference windows TOKENS_LIMIT -> CREDIT_LIMIT when the plans
|
|
941
|
+
# moved to weekly Credits (observed 2026-08 on a Pro plan; the same account read
|
|
942
|
+
# TOKENS_LIMIT in 2026-07). Both spellings stay mapped: an unrecognised type
|
|
943
|
+
# still renders, but as the bare span ("5 hours"), which breaks the like-for-
|
|
944
|
+
# like reading against the claude rows.
|
|
916
945
|
_ZAI_WINDOW_LABELS = {
|
|
946
|
+
("CREDIT_LIMIT", 3, 5): "Session",
|
|
947
|
+
("CREDIT_LIMIT", 6, 1): "Weekly",
|
|
917
948
|
("TOKENS_LIMIT", 3, 5): "Session",
|
|
918
949
|
("TOKENS_LIMIT", 6, 1): "Weekly",
|
|
919
950
|
("TIME_LIMIT", 5, 1): "MCP tools",
|
|
@@ -956,16 +987,22 @@ def _zai_window_label(limit_type: object, unit: object, number: object) -> str:
|
|
|
956
987
|
def zai_probe() -> dict:
|
|
957
988
|
"""GLM Coding Plan quota, for work dispatched through `opencode`.
|
|
958
989
|
|
|
959
|
-
Verified response on a live plan (2026-
|
|
990
|
+
Verified response on a live Pro plan (2026-08):
|
|
960
991
|
|
|
961
992
|
{"code": 200, "success": true, "msg": "Operation successful",
|
|
962
|
-
"data": {"level": "
|
|
963
|
-
{"type": "
|
|
964
|
-
|
|
965
|
-
"nextResetTime":
|
|
966
|
-
{"type": "
|
|
967
|
-
"currentValue":
|
|
968
|
-
"nextResetTime":
|
|
993
|
+
"data": {"level": "pro", "limits": [
|
|
994
|
+
{"type": "CREDIT_LIMIT", "unit": 3, "number": 5, "usage": 12000,
|
|
995
|
+
"currentValue": 21, "remaining": 11978, "percentage": 1,
|
|
996
|
+
"nextResetTime": 1787084800394},
|
|
997
|
+
{"type": "CREDIT_LIMIT", "unit": 6, "number": 1, "usage": 60000,
|
|
998
|
+
"currentValue": 21, "remaining": 59978, "percentage": 1,
|
|
999
|
+
"nextResetTime": 1787671183998}]}}
|
|
1000
|
+
|
|
1001
|
+
Two things moved since 2026-07: the inference windows are typed
|
|
1002
|
+
CREDIT_LIMIT rather than TOKENS_LIMIT (`usage` is the plan's credit
|
|
1003
|
+
allowance — 12k per 5h / 60k per week on Pro), and the monthly TIME_LIMIT
|
|
1004
|
+
pool for MCP tools no longer appears at all. Older accounts may still send
|
|
1005
|
+
the previous shape, so both are mapped.
|
|
969
1006
|
|
|
970
1007
|
`percentage` is percent *used*, `nextResetTime` is epoch ms and is absent
|
|
971
1008
|
until a window has been touched. TIME_LIMIT is the monthly MCP tool pool
|
|
@@ -110,6 +110,34 @@ def test_validate_model_skips_when_roster_unavailable(monkeypatch) -> None:
|
|
|
110
110
|
handler._validate_model("Anything At All")
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def test_available_models_takes_the_slug_column(monkeypatch) -> None:
|
|
114
|
+
# agy 1.1.14 prints a progress line and then `<slug>\t<Display Name>`;
|
|
115
|
+
# `--model` wants the slug. Older builds printed the bare slug — same
|
|
116
|
+
# first token, so both shapes parse.
|
|
117
|
+
import playmaker.agents.agy as agy_mod
|
|
118
|
+
|
|
119
|
+
class _Proc:
|
|
120
|
+
returncode = 0
|
|
121
|
+
stdout = (
|
|
122
|
+
"Fetching available models...\n"
|
|
123
|
+
"gemini-3.7-flash-high\tGemini 3.7 Flash (High)\n"
|
|
124
|
+
"claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
|
|
125
|
+
"gpt-oss-120b-medium\n"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
monkeypatch.setattr(agy_mod.shutil, "which", lambda name: "/usr/local/bin/agy")
|
|
129
|
+
monkeypatch.setattr(agy_mod.subprocess, "run", lambda *a, **kw: _Proc())
|
|
130
|
+
AgyHandler.available_models.cache_clear()
|
|
131
|
+
try:
|
|
132
|
+
assert AgyHandler.available_models() == (
|
|
133
|
+
"gemini-3.7-flash-high",
|
|
134
|
+
"claude-opus-4-6-thinking",
|
|
135
|
+
"gpt-oss-120b-medium",
|
|
136
|
+
)
|
|
137
|
+
finally:
|
|
138
|
+
AgyHandler.available_models.cache_clear()
|
|
139
|
+
|
|
140
|
+
|
|
113
141
|
def test_find_session_file_prefers_full_transcript(tmp_path: Path, monkeypatch) -> None:
|
|
114
142
|
import playmaker.agents.agy as agy_mod
|
|
115
143
|
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Which executable each handler actually launches: [agents.<name>] binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
11
|
+
|
|
12
|
+
import playmaker.config as config
|
|
13
|
+
from playmaker.config import agent_binary
|
|
14
|
+
from playmaker.registry import all_handlers
|
|
15
|
+
|
|
16
|
+
AGENTS = ("claude", "codex", "agy", "gemini", "opencode")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def configured(monkeypatch):
|
|
21
|
+
"""Install a fake config.toml body for the duration of a test."""
|
|
22
|
+
|
|
23
|
+
def _set(cfg: dict) -> None:
|
|
24
|
+
monkeypatch.setattr(config, "load_config", lambda: cfg)
|
|
25
|
+
|
|
26
|
+
return _set
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---- resolution -------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize("agent", AGENTS)
|
|
33
|
+
def test_an_unconfigured_agent_runs_its_own_name(configured, agent) -> None:
|
|
34
|
+
configured({})
|
|
35
|
+
|
|
36
|
+
assert agent_binary(agent) == agent
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize("agent", AGENTS)
|
|
40
|
+
def test_the_configured_binary_wins(configured, agent) -> None:
|
|
41
|
+
configured({"agents": {agent: {"binary": f"/opt/{agent}-nightly/bin/{agent}"}}})
|
|
42
|
+
|
|
43
|
+
assert agent_binary(agent) == f"/opt/{agent}-nightly/bin/{agent}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_a_leading_tilde_is_expanded(configured) -> None:
|
|
47
|
+
# The whole point of the setting is pointing at a CLI that installs under
|
|
48
|
+
# $HOME; subprocess does no shell expansion, so a literal "~" would ENOENT.
|
|
49
|
+
configured({"agents": {"opencode": {"binary": "~/.opencode/bin/opencode"}}})
|
|
50
|
+
|
|
51
|
+
resolved = agent_binary("opencode")
|
|
52
|
+
|
|
53
|
+
assert not resolved.startswith("~")
|
|
54
|
+
assert resolved == str(Path("~/.opencode/bin/opencode").expanduser())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_an_empty_binary_falls_back_to_the_agent_name(configured) -> None:
|
|
58
|
+
# `binary = ""` is a config typo, not a request to exec the empty string.
|
|
59
|
+
configured({"agents": {"agy": {"binary": " "}}})
|
|
60
|
+
|
|
61
|
+
assert agent_binary("agy") == "agy"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_one_agents_binary_does_not_leak_into_another(configured) -> None:
|
|
65
|
+
configured({"agents": {"claude": {"binary": "/opt/claude-nightly"}}})
|
|
66
|
+
|
|
67
|
+
assert agent_binary("claude") == "/opt/claude-nightly"
|
|
68
|
+
assert agent_binary("codex") == "codex"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---- availability -----------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize("agent", AGENTS)
|
|
75
|
+
def test_availability_probes_the_configured_binary(monkeypatch, configured, agent) -> None:
|
|
76
|
+
# `playmaker dispatch` refuses to start when this says no, so a handler that
|
|
77
|
+
# ignored the setting would report "not installed" for a working CLI.
|
|
78
|
+
configured({"agents": {agent: {"binary": f"/opt/{agent}/bin/{agent}"}}})
|
|
79
|
+
probed: list[str] = []
|
|
80
|
+
|
|
81
|
+
def fake_which(cmd, *args, **kwargs):
|
|
82
|
+
probed.append(cmd)
|
|
83
|
+
return cmd
|
|
84
|
+
|
|
85
|
+
for module in ("claude", "codex", "agy", "gemini", "opencode"):
|
|
86
|
+
monkeypatch.setattr(f"playmaker.agents.{module}.shutil.which", fake_which)
|
|
87
|
+
|
|
88
|
+
assert all_handlers()[agent].is_available() is True
|
|
89
|
+
assert probed == [f"/opt/{agent}/bin/{agent}"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize("agent", AGENTS)
|
|
93
|
+
def test_availability_falls_back_to_the_bare_name(monkeypatch, configured, agent) -> None:
|
|
94
|
+
configured({})
|
|
95
|
+
probed: list[str] = []
|
|
96
|
+
|
|
97
|
+
def fake_which(cmd, *args, **kwargs):
|
|
98
|
+
probed.append(cmd)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
for module in ("claude", "codex", "agy", "gemini", "opencode"):
|
|
102
|
+
monkeypatch.setattr(f"playmaker.agents.{module}.shutil.which", fake_which)
|
|
103
|
+
|
|
104
|
+
assert all_handlers()[agent].is_available() is False
|
|
105
|
+
assert probed == [agent]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- the launched command ---------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_claude_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
|
|
112
|
+
configured({"agents": {"claude": {"binary": "/opt/claude-nightly/bin/claude"}}})
|
|
113
|
+
|
|
114
|
+
assert _dispatch_argv0("claude", monkeypatch, tmp_path) == "/opt/claude-nightly/bin/claude"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_codex_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
|
|
118
|
+
configured({"agents": {"codex": {"binary": "/opt/codex/bin/codex"}}})
|
|
119
|
+
|
|
120
|
+
assert _dispatch_argv0("codex", monkeypatch, tmp_path) == "/opt/codex/bin/codex"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_gemini_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
|
|
124
|
+
configured({"agents": {"gemini": {"binary": "/opt/gemini/bin/gemini"}}})
|
|
125
|
+
|
|
126
|
+
assert _dispatch_argv0("gemini", monkeypatch, tmp_path) == "/opt/gemini/bin/gemini"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_agy_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
|
|
130
|
+
configured({"agents": {"agy": {"binary": "/opt/agy/bin/agy"}}})
|
|
131
|
+
|
|
132
|
+
assert _dispatch_argv0("agy", monkeypatch, tmp_path) == "/opt/agy/bin/agy"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_opencode_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
|
|
136
|
+
# The motivating case: opencode installs to ~/.opencode/bin, which only an
|
|
137
|
+
# interactive .zshrc puts on PATH — a detached dispatch needs this setting.
|
|
138
|
+
configured({"agents": {"opencode": {"binary": "~/.opencode/bin/opencode"}}})
|
|
139
|
+
expected = str(Path("~/.opencode/bin/opencode").expanduser())
|
|
140
|
+
|
|
141
|
+
assert _dispatch_argv0("opencode", monkeypatch, tmp_path) == expected
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _dispatch_argv0(agent: str, monkeypatch, tmp_path: Path) -> str:
|
|
145
|
+
"""argv[0] of the process a dispatch would have spawned.
|
|
146
|
+
|
|
147
|
+
Every handler streams its child's stdout, so the fake has to be a Popen
|
|
148
|
+
stand-in rather than a plain recorder; we let each dispatch fail after the
|
|
149
|
+
exec and read the recorded command out of the closure.
|
|
150
|
+
"""
|
|
151
|
+
recorded: list[list[str]] = []
|
|
152
|
+
|
|
153
|
+
class _FakePopen:
|
|
154
|
+
"""Enough of Popen for every handler's read loop: agy polls and writes
|
|
155
|
+
to file handles, the others iterate stdout and read stderr."""
|
|
156
|
+
|
|
157
|
+
returncode = 1
|
|
158
|
+
|
|
159
|
+
def __init__(self, cmd, **kwargs):
|
|
160
|
+
recorded.append(cmd)
|
|
161
|
+
self.stdout = iter(())
|
|
162
|
+
self.stderr = _Reader()
|
|
163
|
+
|
|
164
|
+
def wait(self) -> int:
|
|
165
|
+
return self.returncode
|
|
166
|
+
|
|
167
|
+
def poll(self) -> int:
|
|
168
|
+
return self.returncode
|
|
169
|
+
|
|
170
|
+
def kill(self) -> None:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
class _Reader:
|
|
174
|
+
@staticmethod
|
|
175
|
+
def read() -> str:
|
|
176
|
+
return "stubbed"
|
|
177
|
+
|
|
178
|
+
monkeypatch.setattr(f"playmaker.agents.{agent}.subprocess.Popen", _FakePopen)
|
|
179
|
+
if agent == "opencode":
|
|
180
|
+
# Model validation shells out to `opencode models`; keep it out of the way.
|
|
181
|
+
from playmaker.agents.opencode import OpencodeHandler
|
|
182
|
+
|
|
183
|
+
monkeypatch.setattr(OpencodeHandler, "available_models", staticmethod(tuple))
|
|
184
|
+
if agent == "agy":
|
|
185
|
+
from playmaker.agents.agy import AgyHandler
|
|
186
|
+
|
|
187
|
+
monkeypatch.setattr(AgyHandler, "available_models", staticmethod(tuple))
|
|
188
|
+
|
|
189
|
+
with pytest.raises(RuntimeError):
|
|
190
|
+
all_handlers()[agent].dispatch(prompt="p", cwd=tmp_path)
|
|
191
|
+
|
|
192
|
+
assert len(recorded) == 1
|
|
193
|
+
return recorded[0][0]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---- the shipped template ---------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_every_agent_in_the_template_declares_the_binary_it_defaults_to() -> None:
|
|
200
|
+
# The template is a contract: a key it lists has to be one a handler reads.
|
|
201
|
+
import tomllib
|
|
202
|
+
|
|
203
|
+
from playmaker.cli import _DEFAULT_CONFIG
|
|
204
|
+
|
|
205
|
+
cfg = tomllib.loads(_DEFAULT_CONFIG)
|
|
206
|
+
|
|
207
|
+
for agent in AGENTS:
|
|
208
|
+
assert cfg["agents"][agent]["binary"] == agent
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
3
5
|
import sys
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
|
|
@@ -98,3 +100,110 @@ def test_local_summary_returns_none_when_no_port_answers(monkeypatch) -> None:
|
|
|
98
100
|
monkeypatch.setattr(quotas.urllib.request, "urlopen", boom)
|
|
99
101
|
|
|
100
102
|
assert quotas._antigravity_local_summary([49999]) is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _Response:
|
|
106
|
+
def __init__(self, body: bytes) -> None:
|
|
107
|
+
self._body = body
|
|
108
|
+
|
|
109
|
+
def read(self) -> bytes:
|
|
110
|
+
return self._body
|
|
111
|
+
|
|
112
|
+
def __enter__(self):
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(self, *exc) -> None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_local_summary_skips_a_port_that_does_not_speak_http(monkeypatch) -> None:
|
|
120
|
+
# A TLS-only listener answers a plaintext POST with a TLS alert record;
|
|
121
|
+
# urllib surfaces that as http.client.BadStatusLine, which is not an
|
|
122
|
+
# OSError. It disqualifies that port — it must not sink the probe.
|
|
123
|
+
tls_alert = "\x15\x03\x03\x00\x02\x022"
|
|
124
|
+
|
|
125
|
+
def urlopen(req, timeout=None, context=None):
|
|
126
|
+
if ":50652/" in req.full_url:
|
|
127
|
+
if req.full_url.startswith("https"):
|
|
128
|
+
raise TimeoutError("The read operation timed out")
|
|
129
|
+
raise http.client.BadStatusLine(tls_alert)
|
|
130
|
+
return _Response(json.dumps({"response": _SUMMARY}).encode())
|
|
131
|
+
|
|
132
|
+
monkeypatch.setattr(quotas.urllib.request, "urlopen", urlopen)
|
|
133
|
+
|
|
134
|
+
assert quotas._antigravity_local_summary([50652, 51802]) == _SUMMARY
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_local_summary_ignores_a_port_that_answers_non_object_json(monkeypatch) -> None:
|
|
138
|
+
def urlopen(req, timeout=None, context=None):
|
|
139
|
+
return _Response(b"[]")
|
|
140
|
+
|
|
141
|
+
monkeypatch.setattr(quotas.urllib.request, "urlopen", urlopen)
|
|
142
|
+
|
|
143
|
+
assert quotas._antigravity_local_summary([49999]) is None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_daemon_ports_lists_only_the_daemon_sockets(monkeypatch) -> None:
|
|
147
|
+
# lsof ORs its selectors unless told otherwise, so `-p PID -iTCP` without
|
|
148
|
+
# `-a` is every LISTEN socket on the machine — that is how the probe ended
|
|
149
|
+
# up POSTing to Steam and chromedriver. The lookup must AND them and ask
|
|
150
|
+
# for exactly the pids pgrep found.
|
|
151
|
+
calls: list[list[str]] = []
|
|
152
|
+
|
|
153
|
+
class _Proc:
|
|
154
|
+
def __init__(self, stdout: str) -> None:
|
|
155
|
+
self.stdout = stdout
|
|
156
|
+
self.returncode = 0
|
|
157
|
+
|
|
158
|
+
def run(cmd, **kwargs):
|
|
159
|
+
calls.append(cmd)
|
|
160
|
+
if cmd[0] == "pgrep":
|
|
161
|
+
return _Proc("8577\n" if "agy" in cmd[-1] else "")
|
|
162
|
+
assert cmd[0] == "lsof"
|
|
163
|
+
return _Proc(
|
|
164
|
+
"COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n"
|
|
165
|
+
"agy 8577 me 10u IPv4 0x1 0t0 TCP 127.0.0.1:51802 (LISTEN)\n"
|
|
166
|
+
"agy 8577 me 11u IPv4 0x2 0t0 TCP 127.0.0.1:51803 (LISTEN)\n"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
monkeypatch.setattr(quotas.subprocess, "run", run)
|
|
170
|
+
|
|
171
|
+
assert quotas._antigravity_daemon_ports() == [51802, 51803]
|
|
172
|
+
lsof = [c for c in calls if c[0] == "lsof"]
|
|
173
|
+
assert len(lsof) == 1
|
|
174
|
+
assert "-a" in lsof[0]
|
|
175
|
+
assert lsof[0][lsof[0].index("-p") + 1] == "8577"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_daemon_ports_is_empty_without_a_daemon(monkeypatch) -> None:
|
|
179
|
+
calls: list[list[str]] = []
|
|
180
|
+
|
|
181
|
+
class _Proc:
|
|
182
|
+
stdout = ""
|
|
183
|
+
returncode = 1
|
|
184
|
+
|
|
185
|
+
def run(cmd, **kwargs):
|
|
186
|
+
calls.append(cmd)
|
|
187
|
+
return _Proc()
|
|
188
|
+
|
|
189
|
+
monkeypatch.setattr(quotas.subprocess, "run", run)
|
|
190
|
+
|
|
191
|
+
assert quotas._antigravity_daemon_ports() == []
|
|
192
|
+
# No pids → no lsof at all (an unfiltered lsof is the whole-machine listing).
|
|
193
|
+
assert all(c[0] == "pgrep" for c in calls)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_antigravity_probe_falls_back_to_remote_when_the_local_path_blows_up(
|
|
197
|
+
monkeypatch,
|
|
198
|
+
) -> None:
|
|
199
|
+
def boom():
|
|
200
|
+
raise http.client.BadStatusLine("\x15\x03\x03\x00\x02\x022")
|
|
201
|
+
|
|
202
|
+
monkeypatch.setattr(quotas, "_antigravity_daemon_ports", boom)
|
|
203
|
+
remote = {"status": "ok", "windows": [{"name": "Flash", "pct_left": 100}]}
|
|
204
|
+
monkeypatch.setattr(quotas, "_google_code_assist_probe", lambda base_url, ide_type: remote)
|
|
205
|
+
|
|
206
|
+
result = quotas.antigravity_probe()
|
|
207
|
+
|
|
208
|
+
assert result["status"] == "ok"
|
|
209
|
+
assert result["source"] == "remote"
|
|
@@ -163,3 +163,56 @@ def test_the_environment_is_a_fallback_for_env_injected_configs(monkeypatch, tmp
|
|
|
163
163
|
|
|
164
164
|
assert result["status"] == "ok"
|
|
165
165
|
assert seen["headers"]["Authorization"] == "sk-from-env"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# The same endpoint on a live Pro plan, 2026-08. z.ai renamed the inference
|
|
169
|
+
# windows TOKENS_LIMIT -> CREDIT_LIMIT when plans moved to weekly Credits, and
|
|
170
|
+
# stopped returning the monthly TIME_LIMIT pool altogether.
|
|
171
|
+
_PAYLOAD_CREDITS = {
|
|
172
|
+
"code": 200,
|
|
173
|
+
"msg": "Operation successful",
|
|
174
|
+
"success": True,
|
|
175
|
+
"data": {
|
|
176
|
+
"level": "pro",
|
|
177
|
+
"limits": [
|
|
178
|
+
{
|
|
179
|
+
"type": "CREDIT_LIMIT",
|
|
180
|
+
"unit": 3,
|
|
181
|
+
"number": 5,
|
|
182
|
+
"usage": 12000,
|
|
183
|
+
"currentValue": 21,
|
|
184
|
+
"remaining": 11978,
|
|
185
|
+
"percentage": 1,
|
|
186
|
+
"nextResetTime": 1787084800394,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"type": "CREDIT_LIMIT",
|
|
190
|
+
"unit": 6,
|
|
191
|
+
"number": 1,
|
|
192
|
+
"usage": 60000,
|
|
193
|
+
"currentValue": 21,
|
|
194
|
+
"remaining": 59978,
|
|
195
|
+
"percentage": 1,
|
|
196
|
+
"nextResetTime": 1787671183998,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_credit_limit_windows_read_like_the_token_ones(monkeypatch, keyed) -> None:
|
|
204
|
+
"""The rename must not demote the rows to bare spans ("5 hours"/"1 week").
|
|
205
|
+
|
|
206
|
+
The labels are shared with the claude probe on purpose, so the two providers
|
|
207
|
+
line up in the table; an unmapped type still renders but loses that.
|
|
208
|
+
"""
|
|
209
|
+
_respond(monkeypatch, _PAYLOAD_CREDITS)
|
|
210
|
+
|
|
211
|
+
result = quotas.zai_probe()
|
|
212
|
+
|
|
213
|
+
assert result["status"] == "ok"
|
|
214
|
+
assert result["tier"] == "Pro"
|
|
215
|
+
assert [(w["name"], w["pct_left"]) for w in result["windows"]] == [
|
|
216
|
+
("Session", 99),
|
|
217
|
+
("Weekly", 99),
|
|
218
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|