playmaker-cli 0.9.0__tar.gz → 0.10.0__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.
Files changed (47) hide show
  1. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/CHANGELOG.md +25 -0
  2. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/PKG-INFO +17 -8
  3. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/README.md +16 -7
  4. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/pyproject.toml +1 -1
  5. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/skills/playmaker-coach/references/lanes.md +10 -8
  6. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/skills/playmaker-coach/references/quotas.md +6 -4
  7. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/cli.py +31 -19
  8. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/quotas.py +82 -11
  9. playmaker_cli-0.10.0/tests/test_quotas_claude.py +197 -0
  10. playmaker_cli-0.10.0/tests/test_quotas_codex.py +175 -0
  11. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/.gitignore +0 -0
  12. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/LICENSE +0 -0
  13. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/skills/playmaker-coach/SKILL.md +0 -0
  14. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/skills/playmaker-coach/references/agent-gotchas.md +0 -0
  15. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/skills/playmaker-coach/references/commands.md +0 -0
  16. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/skills/playmaker-coach/references/prompt-templates.md +0 -0
  17. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/skills/playmaker-coach/references/review-board.md +0 -0
  18. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/skills/playmaker-coach/scripts/review-board.sh +0 -0
  19. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/__init__.py +0 -0
  20. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/__main__.py +0 -0
  21. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/agents/__init__.py +0 -0
  22. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/agents/agy.py +0 -0
  23. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/agents/base.py +0 -0
  24. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/agents/claude.py +0 -0
  25. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/agents/codex.py +0 -0
  26. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/agents/gemini.py +0 -0
  27. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/agents/opencode.py +0 -0
  28. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/config.py +0 -0
  29. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/notify.py +0 -0
  30. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/registry.py +0 -0
  31. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/state.py +0 -0
  32. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/src/playmaker/watcher.py +0 -0
  33. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/__init__.py +0 -0
  34. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_agy.py +0 -0
  35. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_batch.py +0 -0
  36. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_binary.py +0 -0
  37. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_claude.py +0 -0
  38. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_codex.py +0 -0
  39. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_no_changes.py +0 -0
  40. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_opencode.py +0 -0
  41. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_permissions.py +0 -0
  42. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_quotas_antigravity.py +0 -0
  43. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_quotas_freshness.py +0 -0
  44. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_quotas_zai.py +0 -0
  45. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_registry.py +0 -0
  46. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_skill.py +0 -0
  47. {playmaker_cli-0.9.0 → playmaker_cli-0.10.0}/tests/test_state.py +0 -0
@@ -5,6 +5,31 @@ 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.10.0] - 2026-09-04
9
+
10
+ ### Added
11
+
12
+ - **`playmaker quotas` shows the two buckets that actually run out first.**
13
+ Codex's `additional_rate_limits[]` carries a separate GPT-5.3-Codex-Spark
14
+ pool with its own 5h and weekly windows, and Anthropic's `limits[]` carries
15
+ a model-scoped weekly next to the all-models one. Both were in the raw
16
+ responses and dropped by the probe, so a coach could route a junior WP to
17
+ Spark while its weekly sat at 4%, or budget a session on the all-models bar
18
+ while its own model-scoped bucket was the one about to end it. Codex now
19
+ renders a `Codex — Spark` sub-block and Claude a `Weekly · <model>` row;
20
+ probes return an additive `blocks` key that `_render_provider` draws for any
21
+ provider.
22
+
23
+ ### Changed
24
+
25
+ - **The coach no longer tiers Antigravity's Claude models by name.**
26
+ `lanes.md` called `agy`'s Claude-Opus "top-tier Claude judgment on Google's
27
+ pool" and offered it as the reviewer to reach for when the Anthropic bucket
28
+ is precious. That roster trails Anthropic's releases by a generation, and
29
+ the name kept pulling real work onto it. The reference now says to tier by
30
+ the version `agy models` shows, and points the second-strong-reviewer row
31
+ at Gemini Pro.
32
+
8
33
  ## [0.9.0] - 2026-08-24
9
34
 
10
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: playmaker-cli
3
- Version: 0.9.0
3
+ Version: 0.10.0
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
@@ -391,14 +391,19 @@ stores, no scraping and no browser.
391
391
  ```console
392
392
  $ playmaker quotas --refresh
393
393
  Claude Max 20x
394
- Session ████████████████░░░░ 80% left resets in 3h 12m
395
- Weekly ███████████░░░░░░░░░ 55% left resets in 4d 6h
396
- Sonnet ███████████████████░ 95% left resets in 4d 6h
394
+ Session ████████████████░░░░ 80% left resets in 3h 12m
395
+ Weekly ███████████░░░░░░░░░ 55% left resets in 4d 6h
396
+ Weekly · Fable ██████░░░░░░░░░░░░░░ 28% left resets in 4d 6h
397
+ Sonnet ███████████████████░ 95% left resets in 4d 6h
397
398
 
398
399
  Codex Plus
399
400
  Session ██████████████████░░ 90% left resets in 1h 40m
400
401
  Weekly █████████████░░░░░░░ 65% left resets in 2d 9h
401
402
 
403
+ Codex — Spark
404
+ Session ████████████████████ 100% left resets in 4h 59m
405
+ Weekly ██░░░░░░░░░░░░░░░░░░ 8% left resets in 5d 22h
406
+
402
407
  Antigravity (agy)
403
408
  Gemini 5h ████████████████████ 100% left
404
409
  Gemini weekly ███████████████████░ 95% left
@@ -411,12 +416,16 @@ Z.ai (GLM, via opencode) Max
411
416
  MCP tools ███████████████████░ 99% left resets in 25d 11h
412
417
  ```
413
418
 
414
- The `Weekly` and `Sonnet` rows above are the point: they are **separate
415
- buckets**. So is every agy row, and so is the whole Z.ai block. Routing a
416
- subtask is choosing which of them to spend.
419
+ The `Weekly`, `Weekly · Fable` and `Sonnet` rows above are the point: they are
420
+ **separate buckets**. So is the `Codex — Spark` block, every agy row, and the
421
+ whole Z.ai block. Routing a subtask is choosing which of them to spend.
417
422
 
418
423
  - **Claude** — OAuth usage API; token from the Claude Code Keychain entry.
419
- - **Codex** ChatGPT `wham/usage` API; token from `~/.codex/auth.json`.
424
+ Model-scoped weekly buckets come from the usage API's `limits[]` array and
425
+ print as `Weekly · <model>`.
426
+ - **Codex** — ChatGPT `wham/usage` API; token from `~/.codex/auth.json`. The
427
+ Spark model's own 5-hour and weekly windows come from
428
+ `additional_rate_limits[]` and print as their own `Codex — Spark` block.
420
429
  - **Antigravity** — prefers agy's **local daemon**
421
430
  (`RetrieveUserQuotaSummary` over its embedded gRPC-web endpoint), which is
422
431
  the only source for the full categorized breakdown above. Works whenever any
@@ -364,14 +364,19 @@ stores, no scraping and no browser.
364
364
  ```console
365
365
  $ playmaker quotas --refresh
366
366
  Claude Max 20x
367
- Session ████████████████░░░░ 80% left resets in 3h 12m
368
- Weekly ███████████░░░░░░░░░ 55% left resets in 4d 6h
369
- Sonnet ███████████████████░ 95% left resets in 4d 6h
367
+ Session ████████████████░░░░ 80% left resets in 3h 12m
368
+ Weekly ███████████░░░░░░░░░ 55% left resets in 4d 6h
369
+ Weekly · Fable ██████░░░░░░░░░░░░░░ 28% left resets in 4d 6h
370
+ Sonnet ███████████████████░ 95% left resets in 4d 6h
370
371
 
371
372
  Codex Plus
372
373
  Session ██████████████████░░ 90% left resets in 1h 40m
373
374
  Weekly █████████████░░░░░░░ 65% left resets in 2d 9h
374
375
 
376
+ Codex — Spark
377
+ Session ████████████████████ 100% left resets in 4h 59m
378
+ Weekly ██░░░░░░░░░░░░░░░░░░ 8% left resets in 5d 22h
379
+
375
380
  Antigravity (agy)
376
381
  Gemini 5h ████████████████████ 100% left
377
382
  Gemini weekly ███████████████████░ 95% left
@@ -384,12 +389,16 @@ Z.ai (GLM, via opencode) Max
384
389
  MCP tools ███████████████████░ 99% left resets in 25d 11h
385
390
  ```
386
391
 
387
- The `Weekly` and `Sonnet` rows above are the point: they are **separate
388
- buckets**. So is every agy row, and so is the whole Z.ai block. Routing a
389
- subtask is choosing which of them to spend.
392
+ The `Weekly`, `Weekly · Fable` and `Sonnet` rows above are the point: they are
393
+ **separate buckets**. So is the `Codex — Spark` block, every agy row, and the
394
+ whole Z.ai block. Routing a subtask is choosing which of them to spend.
390
395
 
391
396
  - **Claude** — OAuth usage API; token from the Claude Code Keychain entry.
392
- - **Codex** ChatGPT `wham/usage` API; token from `~/.codex/auth.json`.
397
+ Model-scoped weekly buckets come from the usage API's `limits[]` array and
398
+ print as `Weekly · <model>`.
399
+ - **Codex** — ChatGPT `wham/usage` API; token from `~/.codex/auth.json`. The
400
+ Spark model's own 5-hour and weekly windows come from
401
+ `additional_rate_limits[]` and print as their own `Codex — Spark` block.
393
402
  - **Antigravity** — prefers agy's **local daemon**
394
403
  (`RetrieveUserQuotaSummary` over its embedded gRPC-web endpoint), which is
395
404
  the only source for the full categorized breakdown above. Works whenever any
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "playmaker-cli"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity and opencode sub-agents in parallel."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -21,9 +21,11 @@ pays. Four lanes:
21
21
  4. **External dispatch — `codex` / `agy` / `opencode`.** Each on its own subscription or plan — the
22
22
  home for write-heavy parallel implementation that can leave the Anthropic subscription.
23
23
  - **`agy` (Antigravity)** carries more than Google models: alongside Gemini Flash and Pro tiers it
24
- serves **Claude Sonnet/Opus (Thinking)** and a GPT-OSS mid tier. Its Claude runs on *Google's*
25
- pool top-tier Claude judgment that spends none of the Anthropic bucket. Note the internal
26
- split: all Gemini models share one bucket, Claude and GPT-OSS share another.
24
+ serves **Claude Sonnet/Opus (Thinking)** and a GPT-OSS tier. Its Claude runs on *Google's*
25
+ pool and spends none of the Anthropic bucket but that roster has trailed Anthropic's own
26
+ releases by a generation (Claude 4.6 on agy while Claude 5 ships on Anthropic), so tier it by
27
+ the version `agy models` shows, never by the name. Note the internal split: all Gemini models
28
+ share one bucket, Claude and GPT-OSS share another.
27
29
  - **`opencode`** is the widest lane: one CLI over ~75 providers addressed as `provider/model` — a
28
30
  GLM coding plan, or a model running locally on this machine, which spends no subscription quota
29
31
  at all.
@@ -40,17 +42,17 @@ against the live roster, failing the dispatch on a stale name.
40
42
  | is an independent stream to monitor separately | `dispatch claude --model sonnet` | tracked, detached, spares the top bucket |
41
43
  | is heavy reasoning only the coach can do | coach | top tier, serial |
42
44
  | is write-heavy and can leave Claude | codex / agy / opencode | their own quotas |
43
- | needs top-tier Claude but the Anthropic bucket is precious | `dispatch agy --model <claude-opus-thinking>` | Claude quality on Google's pool |
45
+ | needs a second strong reviewer without touching the Anthropic bucket | `dispatch agy --model <gemini-pro-high>` | near-senior judgment on an uncontended pool |
44
46
  | is bulk work with every subscription low | `dispatch opencode --model <plan>/<model>` | a separate plan, untouched by the others |
45
47
  | is mechanical and privacy-sensitive, or all quotas spent | `dispatch opencode --model <local>/<model>` | runs on this machine, costs wall-clock only |
46
48
 
47
49
  ## Tier-matching
48
50
 
49
- - **Architectural / spec judgment / cross-module integration** → top tier (coach, agy's Claude-Opus
50
- or Gemini-Pro-high, top-tier Codex).
51
+ - **Architectural / spec judgment / cross-module integration** → top tier (coach, top-tier Codex,
52
+ Gemini-Pro-high on agy for review and advice rather than implementation).
51
53
  - **Pattern-following implementation, scoped CRUD, mechanical refactor, test scaffolding, writing
52
- inside an existing convention** → mid tier (Claude Sonnet, agy's Claude-Sonnet / Gemini-Pro-low,
53
- mid-tier Codex). Most delegated implementation lives here.
54
+ inside an existing convention** → mid tier (Claude Sonnet, Gemini-Pro-low, mid-tier Codex;
55
+ agy's Claude models only when the roster shows a current version). Most delegated implementation lives here.
54
56
  - **Recon, summarization, mechanical loops over many files, normalization** → cheap tier (Flash
55
57
  tiers, Haiku, fast Codex modes).
56
58
 
@@ -13,15 +13,17 @@ the plan instead of quoting the numbers.
13
13
  several tiers with independent buckets, and the whole point of pulling quotas is to push work
14
14
  *away from* the depleted bucket and *toward* the fresh one.
15
15
 
16
- - **Claude:** the top-tier weekly and the mid-tier weekly are **independent**. The coach and
17
- in-session sub-agents spend the top one; the mid tier usually sits idle. Push mid-tier work there
18
- instead of burning the scarce bucket.
16
+ - **Claude:** the block shows the account-wide `Weekly` plus one `Weekly · <model>` row per
17
+ model-scoped bucket (e.g. `Weekly · Fable`). The model-scoped row is the one that empties first
18
+ during a long coach session — read it, not just `Weekly`.
19
19
  - **Antigravity (`agy`):** one Google pool split by family — `Gemini 5h` / `Gemini weekly` and
20
20
  `Claude/GPT 5h` / `Claude/GPT weekly`. All Gemini models share the first; Claude *and* GPT-OSS
21
21
  share the second. So one Gemini reviewer plus one agy-Claude reviewer costs one hit in each of two
22
22
  separate buckets — the cheapest way to buy two independent opinions. Requires agy's local daemon;
23
23
  if the table says "daemon offline" it fell back to a coarse Gemini-only view.
24
- - **Codex:** top-tier versus lighter modes, where the account plan carries them.
24
+ - **Codex:** the main block is the primary Codex window; `Codex — Spark` is a separate block with
25
+ its own `Session` / `Weekly` rows for the Spark model, the junior Codex lane — route Spark work
26
+ off that block, not off the main one.
25
27
  - **opencode:** the quota belongs to the *plan behind the provider*, not to the CLI — it appears
26
28
  under that provider (e.g. a GLM coding plan's session and weekly credit windows) and reads
27
29
  unsupported without a credential. A dispatch pointed at a **local** model spends nothing and never
@@ -978,6 +978,25 @@ def quotas(
978
978
 
979
979
 
980
980
  def _render_provider(name: str, info: dict) -> None:
981
+ def render_windows(windows: list[dict]) -> None:
982
+ # Adaptive name column so longer categorized labels (e.g. "Claude/GPT
983
+ # weekly") keep the bars aligned instead of overflowing a fixed width.
984
+ name_width = max(11, *(len(w["name"]) for w in windows))
985
+ for window in windows:
986
+ pct = window.get("pct_left")
987
+ bar = _bar(pct) if isinstance(pct, int) else " " * 20
988
+ line = f" [bold]{window['name']:<{name_width}}[/bold] {bar} {pct}% left"
989
+ right_parts = []
990
+ if window.get("reset_relative"):
991
+ right_parts.append(f"resets in {window['reset_relative']}")
992
+ if window.get("forecast"):
993
+ right_parts.append(window["forecast"])
994
+ if window.get("reserve_pct") is not None:
995
+ right_parts.append(f"{window['reserve_pct']}% in reserve")
996
+ if right_parts:
997
+ line += f" [dim]{' · '.join(right_parts)}[/dim]"
998
+ console.print(line)
999
+
981
1000
  status = info.get("status")
982
1001
  label_color = {
983
1002
  "codex": "blue",
@@ -1017,27 +1036,13 @@ def _render_provider(name: str, info: dict) -> None:
1017
1036
  return
1018
1037
 
1019
1038
  windows = info.get("windows") or []
1039
+ blocks = info.get("blocks") or []
1020
1040
  if not windows:
1021
1041
  console.print(" [dim]no quota windows reported[/dim]")
1022
- return
1023
-
1024
- # Adaptive name column so longer categorized labels (e.g. "Claude/GPT
1025
- # weekly") keep the bars aligned instead of overflowing a fixed width.
1026
- name_width = max(11, *(len(w["name"]) for w in windows))
1027
- for w in windows:
1028
- pct = w.get("pct_left")
1029
- bar = _bar(pct) if isinstance(pct, int) else " " * 20
1030
- line = f" [bold]{w['name']:<{name_width}}[/bold] {bar} {pct}% left"
1031
- right_parts = []
1032
- if w.get("reset_relative"):
1033
- right_parts.append(f"resets in {w['reset_relative']}")
1034
- if w.get("forecast"):
1035
- right_parts.append(w["forecast"])
1036
- if w.get("reserve_pct") is not None:
1037
- right_parts.append(f"{w['reserve_pct']}% in reserve")
1038
- if right_parts:
1039
- line += f" [dim]{' · '.join(right_parts)}[/dim]"
1040
- console.print(line)
1042
+ if not blocks:
1043
+ return
1044
+ else:
1045
+ render_windows(windows)
1041
1046
 
1042
1047
  # A local provider has models where the metered ones have tiers; naming them
1043
1048
  # saves the coach a round-trip to `ollama list` before it can route here.
@@ -1059,6 +1064,13 @@ def _render_provider(name: str, info: dict) -> None:
1059
1064
  f" [bold]{'Extra usage':<11}[/bold] ${used:.2f} / ${limit:.2f} [dim]{util_str}[/dim]"
1060
1065
  )
1061
1066
 
1067
+ for block in blocks:
1068
+ block_windows = block.get("windows") or []
1069
+ console.print()
1070
+ console.print(f"[bold {label_color}]{display} — {block['name']}[/bold {label_color}]")
1071
+ if block_windows:
1072
+ render_windows(block_windows)
1073
+
1062
1074
 
1063
1075
  def _bar(pct: int, width: int = 20) -> str:
1064
1076
  pct = max(0, min(100, pct))
@@ -242,6 +242,23 @@ def codex_probe() -> dict:
242
242
  raise
243
243
 
244
244
  rate = usage.get("rate_limit") or {}
245
+ reserve = _codex_reserve_from_additional(usage) if rate.get("secondary_window") else None
246
+ windows = _codex_windows(
247
+ rate, reserve_pct=reserve
248
+ )
249
+ spark_windows = _codex_spark_windows(usage)
250
+
251
+ return {
252
+ "status": "ok",
253
+ "account_email": payload.get("email"),
254
+ "tier": _humanize_codex_tier(usage.get("plan_type")),
255
+ "windows": windows,
256
+ "blocks": [{"name": "Spark", "windows": spark_windows}] if spark_windows else [],
257
+ }
258
+
259
+
260
+ def _codex_windows(rate: dict, *, reserve_pct: int | None = None) -> list[dict]:
261
+ """Build the standard Session and Weekly rows for a Codex rate-limit block."""
245
262
  primary = rate.get("primary_window") or {}
246
263
  secondary = rate.get("secondary_window") or {}
247
264
 
@@ -266,10 +283,6 @@ def codex_probe() -> dict:
266
283
  (win_secs - max(0, (reset_at or 0) - time.time())) if reset_at else 0
267
284
  )
268
285
  forecast = _forecast_label(used, win_secs, elapsed)
269
- # "In reserve" — averaged unused weekly across the additional metered
270
- # rate-limits (CodexBar surfaces this so users see they have headroom
271
- # in alternative buckets like Codex-Spark).
272
- reserve = _codex_reserve_from_additional(usage)
273
286
  windows.append(
274
287
  {
275
288
  "name": "Weekly",
@@ -277,16 +290,28 @@ def codex_probe() -> dict:
277
290
  "reset_at_iso": _epoch_to_iso(reset_at),
278
291
  "reset_relative": _format_relative(reset_at),
279
292
  "forecast": forecast,
280
- "reserve_pct": reserve,
293
+ "reserve_pct": reserve_pct,
281
294
  }
282
295
  )
283
296
 
284
- return {
285
- "status": "ok",
286
- "account_email": payload.get("email"),
287
- "tier": _humanize_codex_tier(usage.get("plan_type")),
288
- "windows": windows,
289
- }
297
+ return windows
298
+
299
+
300
+ def _codex_spark_windows(usage: dict) -> list[dict]:
301
+ """Return the Codex-Spark rate-limit rows, if the provider reports them."""
302
+ extras = usage.get("additional_rate_limits") or []
303
+ if not isinstance(extras, list):
304
+ return []
305
+ for extra in extras:
306
+ if not isinstance(extra, dict):
307
+ continue
308
+ feature = extra.get("metered_feature")
309
+ limit_name = extra.get("limit_name")
310
+ if feature == "codex_bengalfox" or (
311
+ isinstance(limit_name, str) and "spark" in limit_name.lower()
312
+ ):
313
+ return _codex_windows(extra.get("rate_limit") or {})
314
+ return []
290
315
 
291
316
 
292
317
  def _codex_reserve_from_additional(usage: dict) -> int | None:
@@ -473,6 +498,52 @@ def claude_probe() -> dict:
473
498
  }
474
499
  )
475
500
 
501
+ scoped_windows: list[dict] = []
502
+ limits = usage.get("limits")
503
+ if isinstance(limits, list):
504
+ for limit in limits:
505
+ if not isinstance(limit, dict) or limit.get("kind") != "weekly_scoped":
506
+ continue
507
+ percent = limit.get("percent")
508
+ scope = limit.get("scope")
509
+ model = scope.get("model") if isinstance(scope, dict) else None
510
+ display_name = model.get("display_name") if isinstance(model, dict) else None
511
+ if isinstance(percent, bool) or not isinstance(percent, (int, float)):
512
+ continue
513
+ if not isinstance(display_name, str):
514
+ continue
515
+
516
+ resets_at = limit.get("resets_at")
517
+ forecast: str | None = None
518
+ if isinstance(resets_at, str):
519
+ try:
520
+ reset_dt = datetime.fromisoformat(resets_at.replace("Z", "+00:00"))
521
+ remaining = reset_dt.timestamp() - now
522
+ elapsed = max(0.0, 7 * 86400 - remaining)
523
+ forecast = _forecast_label(float(percent), 7 * 86400, elapsed)
524
+ except ValueError:
525
+ pass
526
+ scoped_windows.append(
527
+ {
528
+ "name": f"Weekly · {display_name}",
529
+ "pct_left": int(round(100 - float(percent))),
530
+ "reset_at_iso": resets_at,
531
+ "reset_relative": _format_relative(resets_at),
532
+ "forecast": forecast,
533
+ "reserve_pct": None,
534
+ }
535
+ )
536
+
537
+ if scoped_windows:
538
+ weekly_index = next(
539
+ (index for index, window in enumerate(windows) if window["name"] == "Weekly"),
540
+ None,
541
+ )
542
+ if weekly_index is None:
543
+ windows.extend(scoped_windows)
544
+ else:
545
+ windows[weekly_index + 1 : weekly_index + 1] = scoped_windows
546
+
476
547
  # `extra_usage` is the user's monthly overage allowance — surface only
477
548
  # as a top-level field, not as the weekly window's reserve %.
478
549
  extra = usage.get("extra_usage") or {}
@@ -0,0 +1,197 @@
1
+ """Claude quota probe coverage for model-scoped weekly limits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from copy import deepcopy
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
12
+
13
+ import playmaker.quotas as quotas
14
+
15
+ # Captured verbatim from api.anthropic.com/api/oauth/usage on a live Max plan.
16
+ _PAYLOAD = {
17
+ "five_hour": {
18
+ "utilization": 14.0,
19
+ "resets_at": "2026-09-04T19:00:00.208321+00:00",
20
+ "limit_dollars": None,
21
+ "used_dollars": None,
22
+ "remaining_dollars": None,
23
+ "locked_reason": None,
24
+ },
25
+ "seven_day": {
26
+ "utilization": 46.0,
27
+ "resets_at": "2026-09-05T03:00:00.208343+00:00",
28
+ "limit_dollars": None,
29
+ "used_dollars": None,
30
+ "remaining_dollars": None,
31
+ "locked_reason": None,
32
+ },
33
+ "seven_day_oauth_apps": None,
34
+ "seven_day_opus": None,
35
+ "seven_day_sonnet": None,
36
+ "seven_day_cowork": None,
37
+ "seven_day_omelette": None,
38
+ "tangelo": None,
39
+ "iguana_necktie": None,
40
+ "omelette_promotional": None,
41
+ "nimbus_quill": {
42
+ "utilization": 0.0,
43
+ "resets_at": None,
44
+ "limit_dollars": None,
45
+ "used_dollars": None,
46
+ "remaining_dollars": None,
47
+ "locked_reason": None,
48
+ },
49
+ "cinder_cove": None,
50
+ "amber_ladder": None,
51
+ "juniper_tide": None,
52
+ "extra_usage": {
53
+ "is_enabled": False,
54
+ "monthly_limit": 2000,
55
+ "used_credits": 0.0,
56
+ "utilization": 0.0,
57
+ "currency": "USD",
58
+ "decimal_places": 2,
59
+ "disabled_reason": "out_of_credits",
60
+ "user_disabled": False,
61
+ "spend_limit_reached": False,
62
+ "credits_ever_enabled": True,
63
+ "daily": None,
64
+ "weekly": None,
65
+ },
66
+ "limits": [
67
+ {
68
+ "kind": "session",
69
+ "group": "session",
70
+ "percent": 14,
71
+ "severity": "normal",
72
+ "resets_at": "2026-09-04T19:00:00.208321+00:00",
73
+ "scope": None,
74
+ "is_active": False,
75
+ },
76
+ {
77
+ "kind": "weekly_all",
78
+ "group": "weekly",
79
+ "percent": 46,
80
+ "severity": "normal",
81
+ "resets_at": "2026-09-05T03:00:00.208343+00:00",
82
+ "scope": None,
83
+ "is_active": False,
84
+ },
85
+ {
86
+ "kind": "weekly_scoped",
87
+ "group": "weekly",
88
+ "percent": 72,
89
+ "severity": "normal",
90
+ "resets_at": "2026-09-05T03:00:00.208558+00:00",
91
+ "scope": {
92
+ "model": {
93
+ "id": None,
94
+ "display_name": "Fable",
95
+ },
96
+ "surface": None,
97
+ },
98
+ "is_active": True,
99
+ },
100
+ ],
101
+ "spend": {
102
+ "used": {
103
+ "amount_minor": 0,
104
+ "currency": "USD",
105
+ "exponent": 2,
106
+ },
107
+ "limit": {
108
+ "amount_minor": 2000,
109
+ "currency": "USD",
110
+ "exponent": 2,
111
+ },
112
+ "percent": 0,
113
+ "severity": "normal",
114
+ "enabled": False,
115
+ "disabled_reason": "out_of_credits",
116
+ "cap": {
117
+ "money": None,
118
+ "credits": {
119
+ "amount_minor": 2000,
120
+ "exponent": 2,
121
+ },
122
+ },
123
+ "balance": None,
124
+ "auto_reload": None,
125
+ "disclaimer": "Usage credits cover you when you hit your plan limits. [Learn more](https://support.claude.com/articles/12429409)",
126
+ "can_purchase_credits": False,
127
+ "can_toggle": False,
128
+ },
129
+ "member_dashboard_available": False,
130
+ }
131
+
132
+
133
+ def _respond(monkeypatch, payload: dict) -> None:
134
+ monkeypatch.setattr(quotas, "_http_json", lambda *args, **kwargs: payload)
135
+ monkeypatch.setattr(
136
+ quotas,
137
+ "_claude_load_keychain",
138
+ lambda: {
139
+ "accessToken": "tok",
140
+ "expiresAt": 4_000_000_000_000,
141
+ "subscriptionType": "max",
142
+ },
143
+ )
144
+ monkeypatch.setattr(quotas, "_claude_email_from_token", lambda token: None)
145
+
146
+
147
+ def test_model_scoped_weekly_row_is_placed_after_weekly(monkeypatch) -> None:
148
+ _respond(monkeypatch, _PAYLOAD)
149
+
150
+ result = quotas.claude_probe()
151
+
152
+ assert [(window["name"], window["pct_left"]) for window in result["windows"]] == [
153
+ ("Session", 86),
154
+ ("Weekly", 54),
155
+ ("Weekly · Fable", 28),
156
+ ]
157
+ weekly, scoped = result["windows"][1:]
158
+ assert scoped["reset_at_iso"] == _PAYLOAD["limits"][2]["resets_at"]
159
+ assert (scoped["forecast"] is None) == (weekly["forecast"] is None)
160
+
161
+
162
+ def test_inactive_model_scoped_weekly_limit_is_still_rendered(monkeypatch) -> None:
163
+ payload = deepcopy(_PAYLOAD)
164
+ payload["limits"][2]["is_active"] = False
165
+ _respond(monkeypatch, payload)
166
+
167
+ assert "Weekly · Fable" in [window["name"] for window in quotas.claude_probe()["windows"]]
168
+
169
+
170
+ @pytest.mark.parametrize("shape", ["missing_name", "null_limits", "absent_limits"])
171
+ def test_malformed_or_missing_scoped_limits_are_ignored(monkeypatch, shape: str) -> None:
172
+ payload = deepcopy(_PAYLOAD)
173
+ if shape == "missing_name":
174
+ del payload["limits"][2]["scope"]["model"]["display_name"]
175
+ elif shape == "null_limits":
176
+ payload["limits"] = None
177
+ else:
178
+ del payload["limits"]
179
+ _respond(monkeypatch, payload)
180
+
181
+ assert "Weekly · Fable" not in [window["name"] for window in quotas.claude_probe()["windows"]]
182
+
183
+
184
+ def test_scoped_rows_precede_existing_model_window(monkeypatch) -> None:
185
+ payload = deepcopy(_PAYLOAD)
186
+ payload["seven_day_sonnet"] = {
187
+ "utilization": 12.0,
188
+ "resets_at": "2026-09-05T03:00:00.208343+00:00",
189
+ }
190
+ _respond(monkeypatch, payload)
191
+
192
+ assert [window["name"] for window in quotas.claude_probe()["windows"]] == [
193
+ "Session",
194
+ "Weekly",
195
+ "Weekly · Fable",
196
+ "Sonnet",
197
+ ]
@@ -0,0 +1,175 @@
1
+ """Codex quota probe coverage for model-scoped Spark limits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from copy import deepcopy
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ import playmaker.quotas as quotas
12
+ from playmaker import cli
13
+
14
+ # Captured verbatim from chatgpt.com/backend-api/wham/usage on a live Pro Lite
15
+ # plan. The account identifiers are intentionally redacted.
16
+ _PAYLOAD = {
17
+ "user_id": "<redacted>",
18
+ "account_id": "<redacted>",
19
+ "email": "<redacted>",
20
+ "plan_type": "prolite",
21
+ "rate_limit": {
22
+ "allowed": True,
23
+ "limit_reached": False,
24
+ "primary_window": {
25
+ "used_percent": 34,
26
+ "limit_window_seconds": 604800,
27
+ "reset_after_seconds": 218486,
28
+ "reset_at": 1788751180,
29
+ },
30
+ "secondary_window": None,
31
+ },
32
+ "code_review_rate_limit": None,
33
+ "additional_rate_limits": [
34
+ {
35
+ "limit_name": "GPT-5.3-Codex-Spark",
36
+ "metered_feature": "codex_bengalfox",
37
+ "rate_limit": {
38
+ "allowed": True,
39
+ "limit_reached": False,
40
+ "primary_window": {
41
+ "used_percent": 0,
42
+ "limit_window_seconds": 18000,
43
+ "reset_after_seconds": 18000,
44
+ "reset_at": 1788550695,
45
+ },
46
+ "secondary_window": {
47
+ "used_percent": 92,
48
+ "limit_window_seconds": 604800,
49
+ "reset_after_seconds": 511704,
50
+ "reset_at": 1789044398,
51
+ },
52
+ },
53
+ "normal_model_slug": None,
54
+ }
55
+ ],
56
+ "model_usage": {},
57
+ "credits": {
58
+ "has_credits": False,
59
+ "unlimited": False,
60
+ "overage_limit_reached": False,
61
+ "balance": "0",
62
+ "approx_local_messages": [0, 0],
63
+ "approx_cloud_messages": [0, 0],
64
+ },
65
+ "spend_control": {
66
+ "reached": False,
67
+ "individual_limit": None,
68
+ },
69
+ "rate_limit_reached_type": None,
70
+ "promo": None,
71
+ "rate_limit_reset_credits": {
72
+ "available_count": 2,
73
+ "applicable_available_count": 0,
74
+ },
75
+ }
76
+
77
+
78
+ def _respond(monkeypatch, payload: dict) -> None:
79
+ monkeypatch.setattr(quotas, "_http_json", lambda *args, **kwargs: payload)
80
+ monkeypatch.setattr(
81
+ quotas,
82
+ "_codex_load_auth",
83
+ lambda: {"tokens": {"access_token": "tok"}, "account_id": "acct"},
84
+ )
85
+
86
+
87
+ def test_main_and_spark_windows_are_kept_separate(monkeypatch) -> None:
88
+ _respond(monkeypatch, _PAYLOAD)
89
+
90
+ result = quotas.codex_probe()
91
+
92
+ assert [(window["name"], window["pct_left"]) for window in result["windows"]] == [
93
+ ("Session", 66)
94
+ ]
95
+ assert result["windows"][0]["reserve_pct"] is None
96
+ assert [(window["name"], window["pct_left"]) for window in result["blocks"][0]["windows"]] == [
97
+ ("Session", 100),
98
+ ("Weekly", 8),
99
+ ]
100
+ spark_session, spark_weekly = result["blocks"][0]["windows"]
101
+ assert spark_session["forecast"] is None
102
+ assert spark_weekly["reset_at_iso"].startswith("2026-09-")
103
+ assert spark_weekly["reset_relative"]
104
+
105
+
106
+ def test_missing_or_empty_additional_limits_have_no_spark_block(monkeypatch) -> None:
107
+ for additional_limits in ([], None):
108
+ payload = deepcopy(_PAYLOAD)
109
+ if additional_limits is None:
110
+ del payload["additional_rate_limits"]
111
+ else:
112
+ payload["additional_rate_limits"] = additional_limits
113
+ _respond(monkeypatch, payload)
114
+
115
+ assert quotas.codex_probe()["blocks"] == []
116
+
117
+
118
+ def test_non_spark_additional_limit_remains_weekly_reserve(monkeypatch) -> None:
119
+ payload = deepcopy(_PAYLOAD)
120
+ payload["rate_limit"]["secondary_window"] = {
121
+ "used_percent": 25,
122
+ "limit_window_seconds": 604800,
123
+ "reset_after_seconds": 511704,
124
+ "reset_at": 1789044398,
125
+ }
126
+ payload["additional_rate_limits"] = [
127
+ {
128
+ "limit_name": "GPT-5 reserve",
129
+ "metered_feature": "codex_reserve",
130
+ "rate_limit": {
131
+ "secondary_window": {
132
+ "used_percent": 40,
133
+ "limit_window_seconds": 604800,
134
+ "reset_at": 1789044398,
135
+ }
136
+ },
137
+ }
138
+ ]
139
+ _respond(monkeypatch, payload)
140
+
141
+ result = quotas.codex_probe()
142
+
143
+ assert result["blocks"] == []
144
+ assert result["windows"][1]["name"] == "Weekly"
145
+ assert result["windows"][1]["reserve_pct"] == 60
146
+
147
+
148
+ def test_rendering_draws_spark_as_a_separate_block(monkeypatch) -> None:
149
+ _respond(monkeypatch, _PAYLOAD)
150
+ result = quotas.codex_probe()
151
+
152
+ with cli.console.capture() as capture:
153
+ cli._render_provider("codex", result)
154
+ text = capture.get()
155
+
156
+ assert text.count("Codex — Spark") == 1
157
+ assert text.index("Codex — Spark") > text.index("Session")
158
+
159
+ with cli.console.capture() as capture:
160
+ cli._render_provider(
161
+ "codex",
162
+ {
163
+ "status": "ok",
164
+ "windows": [
165
+ {
166
+ "name": "Session",
167
+ "pct_left": 66,
168
+ "reset_relative": None,
169
+ "forecast": None,
170
+ "reserve_pct": None,
171
+ }
172
+ ],
173
+ },
174
+ )
175
+ assert "Codex — Spark" not in capture.get()
File without changes
File without changes