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.
- {aiuse-3.2.7/src/aiuse.egg-info → aiuse-3.2.9}/PKG-INFO +13 -2
- {aiuse-3.2.7 → aiuse-3.2.9}/README.md +12 -1
- {aiuse-3.2.7 → aiuse-3.2.9}/pyproject.toml +1 -1
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/__init__.py +1 -1
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/history.py +4 -1
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/pace.py +9 -2
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/selfdescribe.py +51 -15
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/use_or_lose.py +9 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/chat_format.py +22 -5
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/codexbar.py +45 -6
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/cswap.py +1 -1
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/openusage.py +54 -2
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/runner.py +4 -0
- aiuse-3.2.9/src/aiuse/collectors/throttle.py +197 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/config.py +31 -1
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/models.py +14 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/report.py +18 -1
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/serve.py +3 -0
- {aiuse-3.2.7 → aiuse-3.2.9/src/aiuse.egg-info}/PKG-INFO +13 -2
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/SOURCES.txt +3 -0
- aiuse-3.2.9/tests/test_claude_sublimits.py +206 -0
- aiuse-3.2.9/tests/test_query_throttle.py +181 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/LICENSE +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/setup.cfg +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/__main__.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/agent_notes.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/ai_stub.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/__init__.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/local_runtimes.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/analysis/suggest.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/attribute.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/cli.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/__init__.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/bailian.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/base.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/caut.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/clinepass.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/grok.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/hermes.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/muse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/opencode_go.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/opencode_zen.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/openrouter.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/openusage_sh.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/qwencloud.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/collectors/tokscale.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/credentials.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/ledger.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/macos_trust.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/sampler.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/secretspec.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/tty.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/tui/__init__.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/tui/app.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/tui/builders.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse/watch.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/dependency_links.txt +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/entry_points.txt +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/requires.txt +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/src/aiuse.egg-info/top_level.txt +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_attribution.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_bailian.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_base_run_json.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_caut_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_chat_format.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_cli.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_clinepass.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_codexbar_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_config.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_credentials.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_cswap_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_grok.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_hermes_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_history.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_install_deps.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_local_runtimes.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_macos_trust.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_models_cli_binary.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_models_pace.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_muse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_opencode_go.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_opencode_zen.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_openrouter.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_openusage_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_pace.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_qwencloud.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_release_script.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_report.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_runner_consolidation.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_selfdescribe.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_serve.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_shared_quota_semantics.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_snapshots_persistence.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_suggest.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_tokscale_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_tty.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_tui.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.9}/tests/test_use_or_lose.py +0 -0
- {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.
|
|
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.
|
|
@@ -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
|
|
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,
|
|
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
|
|
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": "
|
|
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.
|
|
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
|
-
("
|
|
58
|
-
("
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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 = [
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
)
|