aiuse 3.2.7__tar.gz → 3.2.9__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 (99) hide show
  1. {aiuse-3.2.7/src/aiuse.egg-info → aiuse-3.2.9}/PKG-INFO +13 -2
  2. {aiuse-3.2.7 → aiuse-3.2.9}/README.md +12 -1
  3. {aiuse-3.2.7 → aiuse-3.2.9}/pyproject.toml +1 -1
  4. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/__init__.py +1 -1
  5. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/history.py +4 -1
  6. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/pace.py +9 -2
  7. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/selfdescribe.py +51 -15
  8. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/use_or_lose.py +9 -0
  9. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/chat_format.py +22 -5
  10. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/codexbar.py +45 -6
  11. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/cswap.py +1 -1
  12. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/openusage.py +54 -2
  13. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/runner.py +4 -0
  14. aiuse-3.2.9/src/aiuse/collectors/throttle.py +197 -0
  15. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/config.py +31 -1
  16. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/models.py +14 -0
  17. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/report.py +18 -1
  18. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/serve.py +3 -0
  19. {aiuse-3.2.7 → aiuse-3.2.9/src/aiuse.egg-info}/PKG-INFO +13 -2
  20. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/SOURCES.txt +3 -0
  21. aiuse-3.2.9/tests/test_claude_sublimits.py +206 -0
  22. aiuse-3.2.9/tests/test_query_throttle.py +181 -0
  23. {aiuse-3.2.7 → aiuse-3.2.9}/LICENSE +0 -0
  24. {aiuse-3.2.7 → aiuse-3.2.9}/setup.cfg +0 -0
  25. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/__main__.py +0 -0
  26. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/agent_notes.py +0 -0
  27. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/ai_stub.py +0 -0
  28. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/__init__.py +0 -0
  29. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/local_runtimes.py +0 -0
  30. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/suggest.py +0 -0
  31. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/attribute.py +0 -0
  32. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/cli.py +0 -0
  33. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/__init__.py +0 -0
  34. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/bailian.py +0 -0
  35. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/base.py +0 -0
  36. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/caut.py +0 -0
  37. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/clinepass.py +0 -0
  38. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/grok.py +0 -0
  39. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/hermes.py +0 -0
  40. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/muse.py +0 -0
  41. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/opencode_go.py +0 -0
  42. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/opencode_zen.py +0 -0
  43. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/openrouter.py +0 -0
  44. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/openusage_sh.py +0 -0
  45. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/qwencloud.py +0 -0
  46. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/tokscale.py +0 -0
  47. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/credentials.py +0 -0
  48. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/ledger.py +0 -0
  49. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/macos_trust.py +0 -0
  50. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/sampler.py +0 -0
  51. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/secretspec.py +0 -0
  52. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/tty.py +0 -0
  53. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/tui/__init__.py +0 -0
  54. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/tui/app.py +0 -0
  55. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/tui/builders.py +0 -0
  56. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/watch.py +0 -0
  57. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/dependency_links.txt +0 -0
  58. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/entry_points.txt +0 -0
  59. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/requires.txt +0 -0
  60. {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/top_level.txt +0 -0
  61. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_attribution.py +0 -0
  62. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_bailian.py +0 -0
  63. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_base_run_json.py +0 -0
  64. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_caut_parse.py +0 -0
  65. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_chat_format.py +0 -0
  66. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_cli.py +0 -0
  67. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_clinepass.py +0 -0
  68. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_codexbar_parse.py +0 -0
  69. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_config.py +0 -0
  70. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_credentials.py +0 -0
  71. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_cswap_parse.py +0 -0
  72. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_grok.py +0 -0
  73. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_hermes_parse.py +0 -0
  74. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_history.py +0 -0
  75. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_install_deps.py +0 -0
  76. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_local_runtimes.py +0 -0
  77. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_macos_trust.py +0 -0
  78. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_models_cli_binary.py +0 -0
  79. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_models_pace.py +0 -0
  80. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_muse.py +0 -0
  81. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_opencode_go.py +0 -0
  82. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_opencode_zen.py +0 -0
  83. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_openrouter.py +0 -0
  84. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_openusage_parse.py +0 -0
  85. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_pace.py +0 -0
  86. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_qwencloud.py +0 -0
  87. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_release_script.py +0 -0
  88. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_report.py +0 -0
  89. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_runner_consolidation.py +0 -0
  90. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_selfdescribe.py +0 -0
  91. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_serve.py +0 -0
  92. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_shared_quota_semantics.py +0 -0
  93. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_snapshots_persistence.py +0 -0
  94. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_suggest.py +0 -0
  95. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_tokscale_parse.py +0 -0
  96. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_tty.py +0 -0
  97. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_tui.py +0 -0
  98. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_use_or_lose.py +0 -0
  99. {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.2.7
3
+ Version: 3.2.9
4
4
  Summary: Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments
5
5
  Author: djbclark
6
6
  License-Expression: MIT
@@ -258,6 +258,17 @@ guess:
258
258
  `aiuse --full` prints this exact table when a multi-account source cannot be
259
259
  matched safely. Explicit mappings take precedence over automatic normalization.
260
260
 
261
+ Some vendors rate-limit quota probes. aiuse reads agy's (Antigravity's) quota
262
+ at most once every 15 minutes across every aiuse process (CLI, LaunchAgent,
263
+ `watch`, `serve`), reusing the last reading in between. Tune or disable it per
264
+ provider, in seconds. See
265
+ [Antigravity pools: query rate limit](docs/antigravity-pools.md#query-rate-limit-2026-10-05):
266
+
267
+ ```toml
268
+ [query_min_interval]
269
+ antigravity = 900 # 0 disables
270
+ ```
271
+
261
272
  ## Daily workflow
262
273
 
263
274
  ```bash
@@ -423,7 +434,7 @@ This tool:
423
434
  1. Pulls **remaining %** and **reset times** from **cswap** (Claude multi-account), **CodexBar** (broad live quotas), **caut**, **OpenUsage.ai**, **OpenUsage.sh** (cross-check peers / fill-in), and **tokscale** (independent measurement; preferred for Copilot).
424
435
  2. Scores windows with **pace-based** logic (default): compare how far through the cycle you are vs how much you've used, then project waste or early lockout.
425
436
  3. Classifies each window as **Burn** (will leave capacity unused), **Conserve** (on track to exhaust before reset — slow down), or **On pace** (no alert).
426
- 4. For **shared-allotment** providers (Claude, Gemini by default), scores the longest governing window only so a fresh 5-hour bar does not outrank the weekly budget it draws from — but genuinely **independent pools are never merged into that governing window**: Cursor's Included+Auto pool and its separate Other Models pool are scored on their own, so an exhausted Other Models pool raises its own alert instead of being masked by a healthy Included.
437
+ 4. For **shared-allotment** providers (Claude, Gemini by default), scores the longest governing window only so a fresh 5-hour bar does not outrank the weekly budget it draws from — but genuinely **independent pools are never merged into that governing window**: Cursor's Included+Auto pool and its separate Other Models pool are scored on their own, so an exhausted Other Models pool raises its own alert instead of being masked by a healthy Included. Claude's **Fable cap is within the shared weekly pool** (up to 50%, not additional quota): the main row shows overall usage, with an indented cap underneath. Fable at 100% does not exhaust other Claude models; shared exhaustion blocks Fable too.
427
438
  5. Qualifies **Conserve** alerts with **overage awareness**: if the account has a real or config-confirmed overage/extra-usage wallet (Claude's usage credits, Cursor's on-demand balance, or a manually confirmed OpenCode Go overage state), the message says so — the real risk there is unplanned $ spend, not lockout, which is a different situation from a genuinely hard ceiling.
428
439
  6. Default pretty output is a **clock matrix** on stdout — one row per account/pool, one column per reset clock (5H/WEEK/MONTH), percentages **used** with that clock's reset after a slash (`75%/4h`, `89%/2d14h`; depleted → prepaid n/a → conserve → mid → use-soon at bottom; read bottom→top). Meta goes to stderr. Use `aiuse --full` for per-provider detail.
429
440
  7. On `--full`, keeps the trailing plan within ~**23 lines × console width** when possible; if the detailed plan is taller, both detailed and **at a glance** are printed (glance last). Forecast fragments (`~lockout …`, projected waste %) appear on table/status lines when pace can project them.
@@ -217,6 +217,17 @@ guess:
217
217
  `aiuse --full` prints this exact table when a multi-account source cannot be
218
218
  matched safely. Explicit mappings take precedence over automatic normalization.
219
219
 
220
+ Some vendors rate-limit quota probes. aiuse reads agy's (Antigravity's) quota
221
+ at most once every 15 minutes across every aiuse process (CLI, LaunchAgent,
222
+ `watch`, `serve`), reusing the last reading in between. Tune or disable it per
223
+ provider, in seconds. See
224
+ [Antigravity pools: query rate limit](docs/antigravity-pools.md#query-rate-limit-2026-10-05):
225
+
226
+ ```toml
227
+ [query_min_interval]
228
+ antigravity = 900 # 0 disables
229
+ ```
230
+
220
231
  ## Daily workflow
221
232
 
222
233
  ```bash
@@ -382,7 +393,7 @@ This tool:
382
393
  1. Pulls **remaining %** and **reset times** from **cswap** (Claude multi-account), **CodexBar** (broad live quotas), **caut**, **OpenUsage.ai**, **OpenUsage.sh** (cross-check peers / fill-in), and **tokscale** (independent measurement; preferred for Copilot).
383
394
  2. Scores windows with **pace-based** logic (default): compare how far through the cycle you are vs how much you've used, then project waste or early lockout.
384
395
  3. Classifies each window as **Burn** (will leave capacity unused), **Conserve** (on track to exhaust before reset — slow down), or **On pace** (no alert).
385
- 4. For **shared-allotment** providers (Claude, Gemini by default), scores the longest governing window only so a fresh 5-hour bar does not outrank the weekly budget it draws from — but genuinely **independent pools are never merged into that governing window**: Cursor's Included+Auto pool and its separate Other Models pool are scored on their own, so an exhausted Other Models pool raises its own alert instead of being masked by a healthy Included.
396
+ 4. For **shared-allotment** providers (Claude, Gemini by default), scores the longest governing window only so a fresh 5-hour bar does not outrank the weekly budget it draws from — but genuinely **independent pools are never merged into that governing window**: Cursor's Included+Auto pool and its separate Other Models pool are scored on their own, so an exhausted Other Models pool raises its own alert instead of being masked by a healthy Included. Claude's **Fable cap is within the shared weekly pool** (up to 50%, not additional quota): the main row shows overall usage, with an indented cap underneath. Fable at 100% does not exhaust other Claude models; shared exhaustion blocks Fable too.
386
397
  5. Qualifies **Conserve** alerts with **overage awareness**: if the account has a real or config-confirmed overage/extra-usage wallet (Claude's usage credits, Cursor's on-demand balance, or a manually confirmed OpenCode Go overage state), the message says so — the real risk there is unplanned $ spend, not lockout, which is a different situation from a genuinely hard ceiling.
387
398
  6. Default pretty output is a **clock matrix** on stdout — one row per account/pool, one column per reset clock (5H/WEEK/MONTH), percentages **used** with that clock's reset after a slash (`75%/4h`, `89%/2d14h`; depleted → prepaid n/a → conserve → mid → use-soon at bottom; read bottom→top). Meta goes to stderr. Use `aiuse --full` for per-provider detail.
388
399
  7. On `--full`, keeps the trailing plan within ~**23 lines × console width** when possible; if the detailed plan is taller, both detailed and **at a glance** are printed (glance last). Forecast fragments (`~lockout …`, projected waste %) appear on table/status lines when pace can project them.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiuse"
7
- version = "3.2.7"
7
+ version = "3.2.9"
8
8
  description = "Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Aggregate provider quotas and flag use-it-or-lose-it allotments."""
2
2
 
3
- __version__ = "3.2.7"
3
+ __version__ = "3.2.9"
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from typing import Any
10
10
 
11
11
  from aiuse.analysis.pace import independent_pool_key
12
- from aiuse.models import Snapshot, canonical_provider, effective_window_minutes, utcnow
12
+ from aiuse.models import Snapshot, canonical_provider, claude_model_scope, effective_window_minutes, utcnow
13
13
 
14
14
  _DEFAULT_SNAPSHOT_DIR = "~/.cache/aiuse/snapshots"
15
15
  _DEFAULT_RETENTION_DAYS = 90
@@ -238,6 +238,9 @@ def window_series_key(provider: str, label: str | None, window_minutes: Any) ->
238
238
  independent pool within it (Gemini vs Claude/GPT), and the window duration.
239
239
  """
240
240
  pool = independent_pool_key(label) or "-"
241
+ model = claude_model_scope(label) if canonical_provider(provider) == "claude" else None
242
+ if model is not None:
243
+ pool = f"model_{model.casefold().replace(' ', '_')}"
241
244
  duration = _duration_key(window_minutes) or "?"
242
245
  return f"{canonical_provider(provider)}:{pool}:{duration}"
243
246
 
@@ -5,7 +5,13 @@ from __future__ import annotations
5
5
 
6
6
  from datetime import datetime, timedelta
7
7
 
8
- from aiuse.models import PaceProfile, QuotaWindow, classify_window_minutes, nominal_window_minutes
8
+ from aiuse.models import (
9
+ PaceProfile,
10
+ QuotaWindow,
11
+ classify_window_minutes,
12
+ claude_model_scope,
13
+ nominal_window_minutes,
14
+ )
9
15
 
10
16
 
11
17
  def compute_pace(
@@ -99,8 +105,9 @@ def governing_partition(windows: list[QuotaWindow]) -> tuple[QuotaWindow | None,
99
105
  window because it temporarily blocks the entire pool, overriding longer durations.
100
106
  When durations tie (e.g. Cursor Included/Auto/other-models all monthly), prefer a
101
107
  window whose label looks like the overall included bar, then list order.
108
+ Claude model caps are children, never whole-pool governing windows.
102
109
  """
103
- usable = [w for w in windows if w.remaining() is not None]
110
+ usable = [w for w in windows if w.remaining() is not None and claude_model_scope(w.label) is None]
104
111
  if not usable:
105
112
  return None, list(windows)
106
113
 
@@ -20,7 +20,7 @@ from __future__ import annotations
20
20
  from datetime import datetime, timedelta
21
21
  from typing import Any, Iterable, Mapping
22
22
 
23
- from aiuse.models import parse_dt, utcnow
23
+ from aiuse.models import claude_model_scope, parse_dt, utcnow
24
24
 
25
25
  SCHEMA_VERSION = "1.1"
26
26
 
@@ -34,12 +34,16 @@ FRESH_THRESHOLD_SECONDS_DEFAULT = 1500.0 # 25 min
34
34
  SEMANTICS: dict[str, str] = {
35
35
  "used_percent": "share CONSUMED; 100 means exhausted, 0 means untouched",
36
36
  "remaining_percent": "share still available; decide from this",
37
- "headroom_percent": "alias of remaining_percent, spelled for humans",
37
+ "headroom_percent": "window: alias of remaining_percent; routing entry: least applicable headroom, with Fable's cap scaled to shared-weekly units",
38
38
  "state": "exhausted (<=1% left) | tight (<15% left) | ok | unknown (no data); never compute it yourself",
39
- "usable_now": "false if any window of the account/pool is exhausted, or no window has data; true only with evidence of headroom",
39
+ "usable_now": "false if any applicable window is exhausted or no shared quota has data; Claude model caps restrict that model only, but shared limits restrict every Claude model",
40
40
  "binding_window": "label of the window with the least remaining_percent — the one that stops you first",
41
41
  "available_at": "earliest resets_at among exhausted windows; null when nothing is exhausted or no reset time is known",
42
- "pool_family": "vendors that split quota by model family (antigravity: gemini vs claude_gpt; claude: default vs fable; cursor: auto/included/other); one family can be exhausted while another is fine — retry on the other family before abandoning the vendor",
42
+ "pool_family": "routing family, not necessarily independent quota: antigravity gemini vs claude_gpt are independent; Claude default is shared quota and fable is a model sublimit within it",
43
+ "quota_scope": "shared or model_sublimit; a model_sublimit is not additional quota and cannot exhaust the whole account",
44
+ "shared_pool_family": "parent routing family whose windows also constrain this model sublimit",
45
+ "max_share_of_parent_percent": "model cap as a share of the shared weekly budget; Fable <=50%, not an extra 50%",
46
+ "headroom_basis": "shared_weekly for Fable routing entries: min(shared headroom, 0.5 * Fable cap headroom); window percentages remain relative to their own limits",
43
47
  "age_seconds": "seconds since collected_at, computed at read time; stale data can hide a fresh exhaustion",
44
48
  "fresh": "age_seconds <= fresh threshold (default 1500s); when false, re-collect before trusting ok states",
45
49
  "summary_lines": "one human line per pool; always shows used AND left, never a bare percentage",
@@ -47,16 +51,15 @@ SEMANTICS: dict[str, str] = {
47
51
  }
48
52
 
49
53
  # (substring, family, models_hint) matched against the lowercased window label,
50
- # first hit wins. Only vendors that genuinely split pools by model family.
54
+ # first hit wins. Claude families are routing views of one shared pool.
51
55
  _FAMILY_RULES: dict[str, list[tuple[str, str, str]]] = {
52
56
  "antigravity": [
53
57
  ("claude/gpt", "claude_gpt", "agy models — claude-* / gpt-* models draw this pool"),
54
58
  ("gemini", "gemini", "agy models — gemini-* models draw this pool"),
55
59
  ],
56
60
  "claude": [
57
- ("fable", "fable", "Claude Fable pool (reasoning-tier models)"),
58
- ("5-hour", "default", "ordinary Claude models"),
59
- ("weekly", "default", "ordinary Claude models"),
61
+ ("5-hour", "default", "Claude models without a reported model cap; quota is shared with capped models"),
62
+ ("weekly", "default", "Claude models without a reported model cap; quota is shared with capped models"),
60
63
  ],
61
64
  "cursor": [
62
65
  ("grok", "grok_bot", "Cursor Grok Bot slot"),
@@ -92,6 +95,9 @@ def window_state(window: Mapping[str, Any]) -> str:
92
95
 
93
96
  def window_family(provider: str, label: str) -> tuple[str | None, str | None]:
94
97
  """(pool_family, models_hint) for split vendors; (None, None) otherwise."""
98
+ model = claude_model_scope(label) if provider == "claude" else None
99
+ if model is not None:
100
+ return model.casefold().replace(" ", "_"), f"Claude {model}; also draws shared Claude quota"
95
101
  rules = _FAMILY_RULES.get(provider)
96
102
  if not rules:
97
103
  return None, None
@@ -112,6 +118,15 @@ def enrich_window(window: dict[str, Any], *, provider: str) -> dict[str, Any]:
112
118
  window.pop("agent_reported", None)
113
119
  window["state"] = window_state(window)
114
120
  window["headroom_percent"] = window_remaining(window)
121
+ for key in ("quota_scope", "shared_pool_family", "max_share_of_parent_percent"):
122
+ window.pop(key, None)
123
+ if provider == "claude":
124
+ model = claude_model_scope(str(window.get("label") or ""))
125
+ window["quota_scope"] = "model_sublimit" if model else "shared"
126
+ if model:
127
+ window["shared_pool_family"] = "default"
128
+ if model.casefold() == "fable":
129
+ window["max_share_of_parent_percent"] = 50.0
115
130
  family, hint = window_family(provider, str(window.get("label") or ""))
116
131
  if family is not None:
117
132
  window["pool_family"] = family
@@ -160,17 +175,18 @@ def enrich_account(
160
175
  if _note_matches(note, provider, window):
161
176
  _apply_note_to_window(window, note)
162
177
 
163
- states = [str(w.get("state")) for w in windows]
178
+ applicable = [w for w in windows if w.get("quota_scope") != "model_sublimit"]
179
+ states = [str(w.get("state")) for w in applicable]
164
180
  if any(s == "exhausted" for s in states):
165
181
  usable_now: bool | None = False
166
- elif windows and any(s != "unknown" for s in states):
182
+ elif applicable and any(s != "unknown" for s in states):
167
183
  usable_now = True
168
184
  else:
169
185
  # no windows at all, or every window unknown: no evidence of headroom
170
186
  usable_now = False
171
187
  account["usable_now"] = usable_now
172
188
 
173
- numbered = [w for w in windows if w.get("headroom_percent") is not None]
189
+ numbered = [w for w in applicable if w.get("headroom_percent") is not None]
174
190
  if numbered:
175
191
  binding = min(numbered, key=lambda w: float(w["headroom_percent"]))
176
192
  account["binding_window"] = str(binding.get("label") or "")
@@ -180,7 +196,7 @@ def enrich_account(
180
196
  account["binding_headroom_percent"] = None
181
197
 
182
198
  reset_times = [
183
- parse_dt(w.get("resets_at")) for w in windows if w.get("state") == "exhausted" and w.get("resets_at")
199
+ parse_dt(w.get("resets_at")) for w in applicable if w.get("state") == "exhausted" and w.get("resets_at")
184
200
  ]
185
201
  reset_times = [t for t in reset_times if t is not None]
186
202
  if reset_times:
@@ -259,11 +275,16 @@ def pool_entries(snap: Mapping[str, Any]) -> list[dict[str, Any]]:
259
275
  if window.get("models_hint"):
260
276
  hints[family] = str(window["models_hint"])
261
277
  for family, windows in by_family.items():
278
+ model_sublimit = provider == "claude" and family != "default"
279
+ family_has_data = any(str(w.get("state") or "unknown") != "unknown" for w in windows)
280
+ shared = by_family.get("default", []) if model_sublimit else windows
281
+ if model_sublimit:
282
+ windows = shared + windows
262
283
  states = [str(w.get("state") or "unknown") for w in windows]
263
284
  numbered = [w for w in windows if w.get("headroom_percent") is not None]
264
285
  if any(s == "exhausted" for s in states):
265
286
  usable: bool | None = False
266
- elif any(s != "unknown" for s in states):
287
+ elif family_has_data and any(str(w.get("state") or "unknown") != "unknown" for w in shared):
267
288
  usable = True
268
289
  else:
269
290
  usable = False
@@ -271,7 +292,12 @@ def pool_entries(snap: Mapping[str, Any]) -> list[dict[str, Any]]:
271
292
  parse_dt(w.get("resets_at")) for w in windows if w.get("state") == "exhausted" and w.get("resets_at")
272
293
  ]
273
294
  resets = [t for t in resets if t is not None]
274
- binding = min(numbered, key=lambda w: float(w["headroom_percent"])) if numbered else None
295
+
296
+ def effective_headroom(window: Mapping[str, Any]) -> float:
297
+ share = float(window.get("max_share_of_parent_percent", 100.0)) / 100.0
298
+ return float(window["headroom_percent"]) * share
299
+
300
+ binding = min(numbered, key=effective_headroom) if numbered else None
275
301
  pools.append(
276
302
  {
277
303
  "provider": provider,
@@ -280,7 +306,14 @@ def pool_entries(snap: Mapping[str, Any]) -> list[dict[str, Any]]:
280
306
  "cli_binary": account.get("cli_binary"),
281
307
  "usable_now": usable,
282
308
  "binding_window": str(binding.get("label") or "") if binding else None,
283
- "headroom_percent": float(binding["headroom_percent"]) if binding else None,
309
+ "headroom_percent": effective_headroom(binding) if binding else None,
310
+ **(
311
+ {"shared_pool_family": "default", "headroom_basis": "shared_weekly"}
312
+ if model_sublimit and family == "fable"
313
+ else {"shared_pool_family": "default"}
314
+ if model_sublimit
315
+ else {}
316
+ ),
284
317
  "available_at": min(resets).isoformat() if resets else None,
285
318
  "models_hint": hints.get(family),
286
319
  "age_seconds": account.get("age_seconds"),
@@ -337,6 +370,9 @@ def summary_line(pool: Mapping[str, Any]) -> str:
337
370
  left = max(0.0, 100.0 - float(used))
338
371
  word = {"exhausted": "EXHAUSTED", "tight": "TIGHT", "ok": "ok", "unknown": "UNKNOWN"}.get(state, state)
339
372
  seg = f"{label}: {word} ({_fmt_pct(used)}% used / {_fmt_pct(left)}% left{_reset_fragment(window.get('resets_at'))})"
373
+ if window.get("quota_scope") == "model_sublimit":
374
+ cap = window.get("max_share_of_parent_percent")
375
+ seg += f" [cap <={cap:g}% of shared weekly]" if cap is not None else " [cap within shared weekly]"
340
376
  if window.get("state_source") == "agent-reported":
341
377
  seg += " [agent-reported]"
342
378
  segments.append(seg)
@@ -38,6 +38,7 @@ from aiuse.models import (
38
38
  UseOrLoseAlert,
39
39
  canonical_provider,
40
40
  classify_window_minutes,
41
+ claude_model_scope,
41
42
  provider_config_key,
42
43
  provider_display_name,
43
44
  utcnow,
@@ -329,6 +330,8 @@ def analyze_use_or_lose(
329
330
  shared_allotment = False
330
331
 
331
332
  for window in account.windows:
333
+ if claude_model_scope(window.label) is not None:
334
+ continue # model caps cannot be burned as additional account quota
332
335
  if mode == "legacy" and _is_short_window(window):
333
336
  continue
334
337
 
@@ -948,6 +951,8 @@ def _pace_message(
948
951
  # suppress shorter siblings that may show 100% open — say so clearly.
949
952
  depleted = remaining is not None and remaining <= 1.0
950
953
  child_note = ""
954
+ model_children = [c for c in suppressed_children or [] if claude_model_scope(c.label) is not None]
955
+ suppressed_children = [c for c in suppressed_children or [] if claude_model_scope(c.label) is None]
951
956
  if suppressed_children:
952
957
  labels = ", ".join(c.label for c in suppressed_children)
953
958
  if verdict == "conserve" and depleted:
@@ -960,6 +965,10 @@ def _pace_message(
960
965
  )
961
966
  else:
962
967
  child_note = f" (this also covers your {labels} — no need to burn it separately)"
968
+ for child in model_children:
969
+ model = claude_model_scope(child.label)
970
+ if child.remaining() == 0:
971
+ child_note += f" {model} cap exhausted; remaining shared quota is for other models."
963
972
  overage_note = (
964
973
  " Overage/extra-usage is available on this account — real risk is unplanned $ spend, not lockout."
965
974
  if pace.has_overage
@@ -23,6 +23,7 @@ from aiuse.models import (
23
23
  Snapshot,
24
24
  UseOrLoseAlert,
25
25
  classify_window_minutes,
26
+ claude_model_scope,
26
27
  provider_display_name,
27
28
  )
28
29
 
@@ -420,8 +421,11 @@ class _PoolEntry:
420
421
  its 5-hour window shows headroom — the shorter window is carved out of
421
422
  the longer one, so the bad news governs.
422
423
  """
423
- worst = self.rows[0].emoji
424
- for row in self.rows[1:]:
424
+ shared = [r for r in self.rows if claude_model_scope(r.window.label) is None]
425
+ if not shared:
426
+ return EMOJI_INFO
427
+ worst = shared[0].emoji
428
+ for row in shared[1:]:
425
429
  worst = _worst_emoji(worst, row.emoji)
426
430
  return worst
427
431
 
@@ -438,7 +442,8 @@ class _PoolEntry:
438
442
 
439
443
  def sort_key(self) -> tuple[Any, ...]:
440
444
  """Order by the entry's most severe window, so the worst float up."""
441
- return min(row.sort_key() for row in self.rows)
445
+ shared = [r for r in self.rows if claude_model_scope(r.window.label) is None]
446
+ return min(row.sort_key() for row in shared or self.rows)
442
447
 
443
448
 
444
449
  def _group_rows_into_pools(rows: list[_WindowRow]) -> list[_PoolEntry]:
@@ -474,7 +479,7 @@ def _apply_governing_warnings(rows: list[_WindowRow]) -> None:
474
479
  report capacity.
475
480
  """
476
481
  for entry in _group_rows_into_pools(rows):
477
- pool_rows = entry.rows
482
+ pool_rows = [r for r in entry.rows if claude_model_scope(r.window.label) is None]
478
483
  if len(pool_rows) < 2:
479
484
  continue
480
485
  # Find the longest-duration window (governing candidate).
@@ -611,7 +616,15 @@ def _build_action_items(
611
616
 
612
617
  if routing_context is not None and sub_rows is not None:
613
618
  primary_prov = routing_context.primary_provider
614
- primary_rows = [r for r in sub_rows if r.provider == primary_prov]
619
+ primary_rows = [
620
+ r
621
+ for r in sub_rows
622
+ if r.provider == primary_prov
623
+ and (
624
+ (scope := claude_model_scope(r.window.label)) is None
625
+ or scope.casefold() in routing_context.primary_model.casefold()
626
+ )
627
+ ]
615
628
  if primary_rows:
616
629
  min_remaining = min(r.remaining for r in primary_rows)
617
630
  name = provider_display_name(primary_prov)
@@ -750,6 +763,10 @@ def _render_prepaid_row(account: AccountUsage) -> list[str]:
750
763
  def _row_notes(row: _WindowRow) -> list[str]:
751
764
  """Continuation lines attached to a single window."""
752
765
  notes: list[str] = []
766
+ model = claude_model_scope(row.window.label)
767
+ if model is not None:
768
+ cap = "<=50% of shared weekly" if model.casefold() == "fable" else "within shared weekly"
769
+ notes.append(f"{model} cap ({cap}); not additional quota")
753
770
  if row.pace_line:
754
771
  notes.append(row.pace_line)
755
772
  if row.remaining <= 0 and not row.pace_line and not row.governing_warning:
@@ -21,6 +21,7 @@ from aiuse.models import coerce_float as _f
21
21
  from aiuse.models import coerce_int as _int_or_none
22
22
 
23
23
  from .base import CollectorError, run_json, which
24
+ from .throttle import QueryGate
24
25
 
25
26
  # Providers that are typically pure prepaid / API balance (not use-or-lose monthly)
26
27
  PREPAID_HINTS = {
@@ -107,6 +108,7 @@ def collect_codexbar(
107
108
  providers: str | list[str] | None = "enabled",
108
109
  timeout: float = 45.0,
109
110
  discovery_timeout: float | None = None,
111
+ min_intervals: dict[str, float] | None = None,
110
112
  ) -> list[AccountUsage]:
111
113
  if not which("codexbar"):
112
114
  raise CollectorError("codexbar not found on PATH")
@@ -124,8 +126,11 @@ def collect_codexbar(
124
126
 
125
127
  accounts: list[AccountUsage] = []
126
128
  errors: list[str] = []
129
+ reuse_notes: dict[str, str] = {}
127
130
 
128
- for provider_arg, outcome in _query_providers(provider_list, timeout=timeout):
131
+ for provider_arg, outcome in _query_providers(
132
+ provider_list, timeout=timeout, min_intervals=min_intervals, reuse_notes=reuse_notes
133
+ ):
129
134
  if isinstance(outcome, CollectorError):
130
135
  name = provider_arg or "enabled providers"
131
136
  errors.append(f"{name}: {outcome}")
@@ -137,7 +142,10 @@ def collect_codexbar(
137
142
  row_provider = str(row.get("provider", "")).strip().lower()
138
143
  if row_provider and row_provider != provider_arg.lower():
139
144
  continue
140
- accounts.append(_from_row(row))
145
+ account = _from_row(row)
146
+ if provider_arg is not None and provider_arg in reuse_notes:
147
+ account.notes = list(account.notes) + [reuse_notes[provider_arg]]
148
+ accounts.append(account)
141
149
  zen = _opencode_zen_from_row(row)
142
150
  if zen is not None:
143
151
  accounts.append(zen)
@@ -202,6 +210,8 @@ def _query_providers(
202
210
  provider_list: list[str | None],
203
211
  *,
204
212
  timeout: float = 45.0,
213
+ min_intervals: dict[str, float] | None = None,
214
+ reuse_notes: dict[str, str] | None = None,
205
215
  ) -> list[tuple[str | None, Any]]:
206
216
  """Run one `codexbar usage` call per entry in provider_list, concurrently.
207
217
 
@@ -210,22 +220,51 @@ def _query_providers(
210
220
  error ordering stays deterministic. Duplicate entries are queried once —
211
221
  querying the same provider twice concurrently is never useful and would
212
222
  otherwise race on which duplicate's outcome survives.
223
+
224
+ Providers listed in ``min_intervals`` go through a cross-process
225
+ :class:`QueryGate`; when one is throttled its last stored payload is
226
+ returned instead and ``reuse_notes[provider]`` explains why.
213
227
  """
214
228
  deduped: list[str | None] = list(dict.fromkeys(provider_list))
229
+ intervals = min_intervals or {}
230
+ notes = reuse_notes if reuse_notes is not None else {}
231
+
232
+ def run(provider_arg: str | None) -> Any:
233
+ if provider_arg is not None and intervals.get(provider_arg.lower(), 0) > 0:
234
+ return _query_provider_gated(provider_arg, intervals[provider_arg.lower()], timeout=timeout, notes=notes)
235
+ return _query_provider(provider_arg, timeout=timeout)
236
+
215
237
  if len(deduped) <= 1:
216
- return [(provider_arg, _query_provider(provider_arg, timeout=timeout)) for provider_arg in deduped]
238
+ return [(provider_arg, run(provider_arg)) for provider_arg in deduped]
217
239
 
218
240
  workers = min(len(deduped), _MAX_CONCURRENT_PROVIDER_QUERIES)
219
241
  with ThreadPoolExecutor(max_workers=workers) as pool:
220
- futures = {
221
- pool.submit(_query_provider, provider_arg, timeout=timeout): provider_arg for provider_arg in deduped
222
- }
242
+ futures = {pool.submit(run, provider_arg): provider_arg for provider_arg in deduped}
223
243
  outcomes: dict[str | None, Any] = {}
224
244
  for future in as_completed(futures):
225
245
  outcomes[futures[future]] = future.result()
226
246
  return [(provider_arg, outcomes[provider_arg]) for provider_arg in deduped]
227
247
 
228
248
 
249
+ def _query_provider_gated(
250
+ provider_arg: str,
251
+ min_interval: float,
252
+ *,
253
+ timeout: float,
254
+ notes: dict[str, str],
255
+ ) -> Any:
256
+ provider = provider_arg.lower()
257
+ with QueryGate(provider, min_interval=min_interval, wait=timeout + 15.0) as gate:
258
+ if gate.allowed:
259
+ outcome = _query_provider(provider_arg, timeout=timeout)
260
+ gate.record("codexbar", outcome)
261
+ return outcome
262
+ reused_at = gate.reused_from("codexbar")
263
+ if reused_at is not None:
264
+ notes[provider_arg] = f"{gate.describe()}; reused CodexBar's result from {reused_at.isoformat()}."
265
+ return gate.reuse("codexbar")
266
+
267
+
229
268
  def _query_provider(provider_arg: str | None, *, timeout: float = 45.0) -> Any:
230
269
  # Omitting --provider asks CodexBar for its enabled-provider list and data
231
270
  # (only reached as a fallback if _discover_enabled_providers() fails).
@@ -182,7 +182,7 @@ def _windows_from_usage(usage: dict[str, Any]) -> list[QuotaWindow]:
182
182
  continue
183
183
  model_name = str(block.get("name") or "unnamed model")
184
184
  window = _window_from_block(f"Claude Code weekly — {model_name}", block)
185
- if window and not _same_window_present(windows, window):
185
+ if window and not any(w.label == window.label and w.same_measurement(window) for w in windows):
186
186
  windows.append(window)
187
187
 
188
188
  return windows
@@ -15,6 +15,7 @@ import subprocess
15
15
  import urllib.error
16
16
  import urllib.parse
17
17
  import urllib.request
18
+ from contextlib import ExitStack
18
19
  from typing import Any
19
20
 
20
21
  from aiuse.models import (
@@ -30,6 +31,7 @@ from aiuse.models import (
30
31
  )
31
32
 
32
33
  from .base import CollectorError, run_json, which
34
+ from .throttle import QueryGate
33
35
 
34
36
  DEFAULT_OPENUSAGE_BASE = "http://127.0.0.1:6736"
35
37
  DEFAULT_HTTP_TIMEOUT = 30.0
@@ -96,13 +98,15 @@ def collect_openusage_ai(
96
98
  base_url: str = DEFAULT_OPENUSAGE_BASE,
97
99
  force_refresh: bool = True,
98
100
  try_launch_app: bool = True,
101
+ min_intervals: dict[str, float] | None = None,
99
102
  ) -> list[AccountUsage]:
100
103
  """Fetch OpenUsage limits via CLI first, then loopback HTTP."""
101
- payload, via = _fetch_limits(
104
+ payload, via, throttle_note = _fetch_limits_gated(
102
105
  timeout=timeout,
103
106
  base_url=base_url,
104
107
  force_refresh=force_refresh,
105
108
  try_launch_app=try_launch_app,
109
+ min_intervals=min_intervals or {},
106
110
  )
107
111
  if not isinstance(payload, dict):
108
112
  raise CollectorError("OpenUsage returned non-object JSON")
@@ -120,7 +124,10 @@ def collect_openusage_ai(
120
124
  for provider_id, body in providers.items():
121
125
  if not isinstance(body, dict):
122
126
  continue
123
- accounts.append(_from_provider(str(provider_id), body, via=via))
127
+ account = _from_provider(str(provider_id), body, via=via)
128
+ if throttle_note and account.provider in _gated_providers(min_intervals):
129
+ account.notes = list(account.notes) + [throttle_note]
130
+ accounts.append(account)
124
131
 
125
132
  errors = payload.get("errors") or []
126
133
  if errors and accounts:
@@ -142,6 +149,51 @@ def collect_openusage_ai(
142
149
  return accounts
143
150
 
144
151
 
152
+ def _gated_providers(min_intervals: dict[str, float] | None) -> set[str]:
153
+ return {provider for provider, seconds in (min_intervals or {}).items() if seconds > 0}
154
+
155
+
156
+ def _fetch_limits_gated(
157
+ *,
158
+ timeout: float,
159
+ base_url: str,
160
+ force_refresh: bool,
161
+ try_launch_app: bool,
162
+ min_intervals: dict[str, float],
163
+ ) -> tuple[dict[str, Any], str, str | None]:
164
+ """``_fetch_limits`` with ``--force`` withheld while any gated provider is throttled.
165
+
166
+ A forced CLI refresh makes OpenUsage re-query every provider it has enabled,
167
+ so it counts as a query of each gated provider that comes back in the
168
+ payload. Without ``--force`` OpenUsage serves its own cached reading.
169
+ """
170
+ gated = sorted(_gated_providers(min_intervals))
171
+ if not force_refresh or not gated or not which("openusage"):
172
+ payload, via = _fetch_limits(
173
+ timeout=timeout, base_url=base_url, force_refresh=force_refresh, try_launch_app=try_launch_app
174
+ )
175
+ return payload, via, None
176
+
177
+ with ExitStack() as stack:
178
+ gates = [
179
+ stack.enter_context(QueryGate(provider, min_interval=min_intervals[provider], wait=timeout + 15.0))
180
+ for provider in gated
181
+ ]
182
+ blocked = [gate for gate in gates if not gate.allowed]
183
+ payload, via = _fetch_limits(
184
+ timeout=timeout, base_url=base_url, force_refresh=not blocked, try_launch_app=try_launch_app
185
+ )
186
+ if blocked:
187
+ reasons = "; ".join(gate.describe() for gate in blocked)
188
+ return payload, via, f"OpenUsage refresh not forced: {reasons}."
189
+ if via == "cli":
190
+ returned = payload.get("providers") if isinstance(payload, dict) else None
191
+ for gate in gates:
192
+ if isinstance(returned, dict) and gate.provider in {str(k).lower() for k in returned}:
193
+ gate.record("openusage_ai", store=False)
194
+ return payload, via, None
195
+
196
+
145
197
  def _fetch_limits(
146
198
  *,
147
199
  timeout: float,
@@ -35,6 +35,7 @@ from .openrouter import collect_openrouter
35
35
  from .openusage import collect_openusage_ai
36
36
  from .openusage_sh import collect_openusage_sh
37
37
  from .qwencloud import collect_qwencloud
38
+ from .throttle import min_intervals
38
39
  from .tokscale import collect_tokscale
39
40
 
40
41
  # Prefer earlier sources for *selection* (what drives the ladder). All live
@@ -95,6 +96,7 @@ def run_collectors(config: dict[str, Any] | None = None) -> Snapshot:
95
96
  config = config or {}
96
97
  collectors_cfg = config.get("collectors") or {}
97
98
  snapshot = Snapshot(collected_at=utcnow())
99
+ intervals = min_intervals(config)
98
100
 
99
101
  # Each collector shells out (or hits loopback) independently — run concurrently.
100
102
  # Correctness: long default timeouts; all enabled sources always queried.
@@ -114,6 +116,7 @@ def run_collectors(config: dict[str, Any] | None = None) -> Snapshot:
114
116
  providers=providers,
115
117
  timeout=codexbar_timeout,
116
118
  discovery_timeout=discovery_timeout,
119
+ min_intervals=intervals,
117
120
  ),
118
121
  )
119
122
  )
@@ -144,6 +147,7 @@ def run_collectors(config: dict[str, Any] | None = None) -> Snapshot:
144
147
  force_refresh=force,
145
148
  try_launch_app=launch,
146
149
  base_url=base,
150
+ min_intervals=intervals,
147
151
  ),
148
152
  )
149
153
  )