aiuse 3.2.7__tar.gz → 3.2.8__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.8}/PKG-INFO +12 -1
- {aiuse-3.2.7 → aiuse-3.2.8}/README.md +11 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/pyproject.toml +1 -1
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/__init__.py +1 -1
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/codexbar.py +45 -6
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/openusage.py +54 -2
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/runner.py +4 -0
- aiuse-3.2.8/src/aiuse/collectors/throttle.py +197 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/config.py +31 -1
- {aiuse-3.2.7 → aiuse-3.2.8/src/aiuse.egg-info}/PKG-INFO +12 -1
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/SOURCES.txt +2 -0
- aiuse-3.2.8/tests/test_query_throttle.py +181 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/LICENSE +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/setup.cfg +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/__main__.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/agent_notes.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/ai_stub.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/__init__.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/history.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/local_runtimes.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/pace.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/selfdescribe.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/suggest.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/use_or_lose.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/attribute.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/chat_format.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/cli.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/__init__.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/bailian.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/base.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/caut.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/clinepass.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/cswap.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/grok.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/hermes.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/muse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/opencode_go.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/opencode_zen.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/openrouter.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/openusage_sh.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/qwencloud.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/tokscale.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/credentials.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/ledger.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/macos_trust.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/models.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/report.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/sampler.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/secretspec.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/serve.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/tty.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/tui/__init__.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/tui/app.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/tui/builders.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/watch.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/dependency_links.txt +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/entry_points.txt +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/requires.txt +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/top_level.txt +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_attribution.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_bailian.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_base_run_json.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_caut_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_chat_format.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_cli.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_clinepass.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_codexbar_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_config.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_credentials.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_cswap_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_grok.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_hermes_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_history.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_install_deps.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_local_runtimes.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_macos_trust.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_models_cli_binary.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_models_pace.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_muse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_opencode_go.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_opencode_zen.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_openrouter.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_openusage_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_pace.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_qwencloud.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_release_script.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_report.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_runner_consolidation.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_selfdescribe.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_serve.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_shared_quota_semantics.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_snapshots_persistence.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_suggest.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_tokscale_parse.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_tty.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_tui.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_use_or_lose.py +0 -0
- {aiuse-3.2.7 → aiuse-3.2.8}/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.8
|
|
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
|
|
@@ -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
|
|
@@ -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).
|
|
@@ -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
|
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Cross-process minimum interval between live quota queries for one provider.
|
|
2
|
+
|
|
3
|
+
Some vendors punish a burst of quota probes with rate limits that also stop the
|
|
4
|
+
user's real work: agy (antigravity) answers queries in quick succession with
|
|
5
|
+
429s on every model. Every aiuse entry point — the CLI, the ``aiuse sample``
|
|
6
|
+
LaunchAgent, ``aiuse watch``, ``aiuse serve`` — collects through the same
|
|
7
|
+
collectors, and several can run at once, so the limit is enforced here through
|
|
8
|
+
a locked state file per provider rather than inside any one process.
|
|
9
|
+
|
|
10
|
+
While a provider is throttled, a collector reuses the payload it stored on its
|
|
11
|
+
last allowed query (CodexBar rows carry their own ``updatedAt``, so the reused
|
|
12
|
+
row reports its true age) or skips the part of its call that would reach the
|
|
13
|
+
vendor (OpenUsage's ``--force``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import fcntl
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from aiuse.analysis import history
|
|
27
|
+
from aiuse.config import DEFAULT_QUERY_MIN_INTERVAL
|
|
28
|
+
from aiuse.models import parse_dt, utcnow
|
|
29
|
+
|
|
30
|
+
from .base import CollectorError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def throttle_dir() -> Path:
|
|
34
|
+
return history.snapshot_dir().parent / "query-throttle"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def min_intervals(config: dict[str, Any] | None) -> dict[str, float]:
|
|
38
|
+
"""Provider -> minimum seconds between live queries (config over defaults)."""
|
|
39
|
+
table = dict(DEFAULT_QUERY_MIN_INTERVAL)
|
|
40
|
+
configured = (config or {}).get("query_min_interval")
|
|
41
|
+
if isinstance(configured, dict):
|
|
42
|
+
for provider, value in configured.items():
|
|
43
|
+
try:
|
|
44
|
+
table[str(provider).strip().lower()] = max(0.0, float(value))
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
continue
|
|
47
|
+
return {provider: seconds for provider, seconds in table.items() if seconds > 0}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format_duration(seconds: float) -> str:
|
|
51
|
+
seconds = max(0, int(round(seconds)))
|
|
52
|
+
if seconds < 60:
|
|
53
|
+
return f"{seconds}s"
|
|
54
|
+
minutes, secs = divmod(seconds, 60)
|
|
55
|
+
if minutes < 60:
|
|
56
|
+
return f"{minutes}m{secs:02d}s" if secs else f"{minutes}m"
|
|
57
|
+
hours, minutes = divmod(minutes, 60)
|
|
58
|
+
return f"{hours}h{minutes:02d}m"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class QueryGate:
|
|
62
|
+
"""Hold one provider's query lock and decide whether a live query may run.
|
|
63
|
+
|
|
64
|
+
Usage::
|
|
65
|
+
|
|
66
|
+
with QueryGate("antigravity", min_interval=900, wait=60) as gate:
|
|
67
|
+
if gate.allowed:
|
|
68
|
+
outcome = query()
|
|
69
|
+
gate.record("codexbar", outcome)
|
|
70
|
+
else:
|
|
71
|
+
outcome = gate.reuse("codexbar")
|
|
72
|
+
|
|
73
|
+
The lock is held for the whole block, so a second process arriving mid-query
|
|
74
|
+
waits (up to ``wait`` seconds) and then sees the fresh ``queried_at`` instead
|
|
75
|
+
of firing its own query. A process that cannot get the lock in time is
|
|
76
|
+
treated as throttled.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
provider: str,
|
|
82
|
+
*,
|
|
83
|
+
min_interval: float,
|
|
84
|
+
wait: float,
|
|
85
|
+
directory: Path | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.provider = provider
|
|
88
|
+
self.min_interval = float(min_interval)
|
|
89
|
+
self.wait = max(0.0, float(wait))
|
|
90
|
+
self.directory = directory if directory is not None else throttle_dir()
|
|
91
|
+
self.allowed = False
|
|
92
|
+
self.locked = False
|
|
93
|
+
self.state: dict[str, Any] = {}
|
|
94
|
+
self.now: datetime = utcnow()
|
|
95
|
+
self._lock_fd: int | None = None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def _state_path(self) -> Path:
|
|
99
|
+
return self.directory / f"{self.provider}.json"
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def last_queried_at(self) -> datetime | None:
|
|
103
|
+
return parse_dt(self.state.get("queried_at"))
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def next_allowed_at(self) -> datetime | None:
|
|
107
|
+
last = self.last_queried_at
|
|
108
|
+
return None if last is None else last + timedelta(seconds=self.min_interval)
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> QueryGate:
|
|
111
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
self._lock_fd = os.open(self.directory / f"{self.provider}.lock", os.O_CREAT | os.O_RDWR, 0o600)
|
|
113
|
+
deadline = time.monotonic() + self.wait
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
fcntl.flock(self._lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
117
|
+
self.locked = True
|
|
118
|
+
break
|
|
119
|
+
except BlockingIOError:
|
|
120
|
+
if time.monotonic() >= deadline:
|
|
121
|
+
break
|
|
122
|
+
time.sleep(0.25)
|
|
123
|
+
self.state = self._read_state()
|
|
124
|
+
self.now = utcnow()
|
|
125
|
+
last = self.last_queried_at
|
|
126
|
+
self.allowed = self.locked and (
|
|
127
|
+
last is None or last > self.now or (self.now - last).total_seconds() >= self.min_interval
|
|
128
|
+
)
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, *exc: object) -> None:
|
|
132
|
+
if self._lock_fd is not None:
|
|
133
|
+
if self.locked:
|
|
134
|
+
fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
|
|
135
|
+
os.close(self._lock_fd)
|
|
136
|
+
self._lock_fd = None
|
|
137
|
+
|
|
138
|
+
def _read_state(self) -> dict[str, Any]:
|
|
139
|
+
try:
|
|
140
|
+
data = json.loads(self._state_path.read_text(encoding="utf-8"))
|
|
141
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
142
|
+
return {}
|
|
143
|
+
return data if isinstance(data, dict) else {}
|
|
144
|
+
|
|
145
|
+
def record(self, source: str, outcome: Any = None, *, store: bool = True) -> None:
|
|
146
|
+
"""Mark a live query by ``source`` as of now (after it ran); optionally keep its outcome."""
|
|
147
|
+
if not self.locked:
|
|
148
|
+
return
|
|
149
|
+
at = utcnow()
|
|
150
|
+
self.state["queried_at"] = at.isoformat()
|
|
151
|
+
self.state["queried_by"] = source
|
|
152
|
+
if store:
|
|
153
|
+
entry: dict[str, Any] = {"fetched_at": at.isoformat()}
|
|
154
|
+
if isinstance(outcome, BaseException):
|
|
155
|
+
entry["error"] = str(outcome)
|
|
156
|
+
else:
|
|
157
|
+
entry["payload"] = outcome
|
|
158
|
+
self.state.setdefault("sources", {})[source] = entry
|
|
159
|
+
tmp = self._state_path.with_suffix(f".json.{os.getpid()}.tmp")
|
|
160
|
+
try:
|
|
161
|
+
tmp.write_text(json.dumps(self.state, default=str), encoding="utf-8")
|
|
162
|
+
os.replace(tmp, self._state_path)
|
|
163
|
+
except OSError:
|
|
164
|
+
tmp.unlink(missing_ok=True)
|
|
165
|
+
|
|
166
|
+
def describe(self) -> str:
|
|
167
|
+
"""Human-readable reason a query was skipped (for notes and errors)."""
|
|
168
|
+
interval = _format_duration(self.min_interval)
|
|
169
|
+
last = self.last_queried_at
|
|
170
|
+
if not self.locked and last is None:
|
|
171
|
+
return f"{self.provider} quota query skipped: another aiuse process is querying it"
|
|
172
|
+
if last is None:
|
|
173
|
+
return f"{self.provider} quota queried at most every {interval}"
|
|
174
|
+
age = _format_duration((self.now - last).total_seconds())
|
|
175
|
+
nxt = self.next_allowed_at
|
|
176
|
+
remaining = _format_duration((nxt - self.now).total_seconds()) if nxt else "0s"
|
|
177
|
+
by = self.state.get("queried_by") or "aiuse"
|
|
178
|
+
return (
|
|
179
|
+
f"{self.provider} quota queried at most every {interval} "
|
|
180
|
+
f"(last query {age} ago via {by}; next allowed in {remaining})"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def reuse(self, source: str) -> Any:
|
|
184
|
+
"""The payload ``source`` stored on its last allowed query, else a CollectorError."""
|
|
185
|
+
entry = (self.state.get("sources") or {}).get(source)
|
|
186
|
+
if isinstance(entry, dict):
|
|
187
|
+
if "payload" in entry:
|
|
188
|
+
return entry["payload"]
|
|
189
|
+
if entry.get("error"):
|
|
190
|
+
return CollectorError(f"{entry['error']} [{self.describe()}]")
|
|
191
|
+
return CollectorError(self.describe())
|
|
192
|
+
|
|
193
|
+
def reused_from(self, source: str) -> datetime | None:
|
|
194
|
+
entry = (self.state.get("sources") or {}).get(source)
|
|
195
|
+
if isinstance(entry, dict) and "payload" in entry:
|
|
196
|
+
return parse_dt(entry.get("fetched_at"))
|
|
197
|
+
return None
|
|
@@ -24,6 +24,10 @@ CSWAP_TIMEOUT = 90.0
|
|
|
24
24
|
# `openusage-sh export` polls every configured provider. A quiet run is about
|
|
25
25
|
# 30s, and the same export often crosses 45s while the other collectors run.
|
|
26
26
|
OPENUSAGE_SH_TIMEOUT = 90.0
|
|
27
|
+
# Minimum seconds between live quota queries per provider, shared by every aiuse
|
|
28
|
+
# process (collectors/throttle.py). agy answers probes in quick succession with
|
|
29
|
+
# 429s on every model, so its quota is read at most once per 15 minutes.
|
|
30
|
+
DEFAULT_QUERY_MIN_INTERVAL: dict[str, float] = {"antigravity": 900.0}
|
|
27
31
|
|
|
28
32
|
DEFAULT_CONFIG: dict[str, Any] = {
|
|
29
33
|
# Subprocess timeouts (seconds). ``default`` applies to any tool that does
|
|
@@ -226,6 +230,9 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
226
230
|
"qwencloud": {"enabled": True},
|
|
227
231
|
"bailian": {"enabled": True},
|
|
228
232
|
},
|
|
233
|
+
# Provider id -> minimum seconds between live quota queries, across all aiuse
|
|
234
|
+
# processes (CLI, `aiuse sample`, watch, serve). 0 disables the limit.
|
|
235
|
+
"query_min_interval": dict(DEFAULT_QUERY_MIN_INTERVAL),
|
|
229
236
|
# Source-specific local account ids can be mapped to the account label used
|
|
230
237
|
# by other collectors. Normally this stays empty: runner.py automatically
|
|
231
238
|
# resolves providers where every source has exactly one named account.
|
|
@@ -267,7 +274,17 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
267
274
|
|
|
268
275
|
# Top-level and nested keys recognized by the loader / doctor (unknown → warning).
|
|
269
276
|
KNOWN_TOP_LEVEL_KEYS = frozenset(
|
|
270
|
-
{
|
|
277
|
+
{
|
|
278
|
+
"timeouts",
|
|
279
|
+
"analysis",
|
|
280
|
+
"plans",
|
|
281
|
+
"collectors",
|
|
282
|
+
"query_min_interval",
|
|
283
|
+
"account_aliases",
|
|
284
|
+
"macos",
|
|
285
|
+
"sampling",
|
|
286
|
+
"attribution",
|
|
287
|
+
}
|
|
271
288
|
)
|
|
272
289
|
KNOWN_TIMEOUT_KEYS = frozenset(
|
|
273
290
|
{
|
|
@@ -446,6 +463,19 @@ def validate_config(config: dict[str, Any] | None) -> list[str]:
|
|
|
446
463
|
if ek not in KNOWN_COLLECTOR_ENTRY_KEYS:
|
|
447
464
|
issues.append(f"warning: unknown collectors.{name} key {ek!r}")
|
|
448
465
|
|
|
466
|
+
query_min_interval = cfg.get("query_min_interval")
|
|
467
|
+
if query_min_interval is not None and not isinstance(query_min_interval, dict):
|
|
468
|
+
issues.append("error: query_min_interval must be a provider -> seconds mapping")
|
|
469
|
+
elif isinstance(query_min_interval, dict):
|
|
470
|
+
for provider, value in query_min_interval.items():
|
|
471
|
+
try:
|
|
472
|
+
seconds = float(value)
|
|
473
|
+
except (TypeError, ValueError):
|
|
474
|
+
issues.append(f"error: query_min_interval.{provider} must be a number of seconds (got {value!r})")
|
|
475
|
+
continue
|
|
476
|
+
if seconds < 0:
|
|
477
|
+
issues.append(f"error: query_min_interval.{provider} must not be negative (got {seconds:g})")
|
|
478
|
+
|
|
449
479
|
aliases = cfg.get("account_aliases")
|
|
450
480
|
if aliases is not None and not isinstance(aliases, dict):
|
|
451
481
|
issues.append("error: account_aliases must be a provider -> source -> account mapping")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiuse
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.8
|
|
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
|
|
@@ -49,6 +49,7 @@ src/aiuse/collectors/openusage.py
|
|
|
49
49
|
src/aiuse/collectors/openusage_sh.py
|
|
50
50
|
src/aiuse/collectors/qwencloud.py
|
|
51
51
|
src/aiuse/collectors/runner.py
|
|
52
|
+
src/aiuse/collectors/throttle.py
|
|
52
53
|
src/aiuse/collectors/tokscale.py
|
|
53
54
|
src/aiuse/tui/__init__.py
|
|
54
55
|
src/aiuse/tui/app.py
|
|
@@ -78,6 +79,7 @@ tests/test_opencode_zen.py
|
|
|
78
79
|
tests/test_openrouter.py
|
|
79
80
|
tests/test_openusage_parse.py
|
|
80
81
|
tests/test_pace.py
|
|
82
|
+
tests/test_query_throttle.py
|
|
81
83
|
tests/test_qwencloud.py
|
|
82
84
|
tests/test_release_script.py
|
|
83
85
|
tests/test_report.py
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Cross-process minimum interval between live quota queries (agy / antigravity)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from aiuse.collectors import codexbar, openusage
|
|
9
|
+
from aiuse.collectors.base import CollectorError
|
|
10
|
+
from aiuse.collectors.throttle import QueryGate, min_intervals, throttle_dir
|
|
11
|
+
from aiuse.config import DEFAULT_CONFIG, validate_config
|
|
12
|
+
from aiuse.models import utcnow
|
|
13
|
+
|
|
14
|
+
_AGY_ROW = {
|
|
15
|
+
"provider": "antigravity",
|
|
16
|
+
"source": "local",
|
|
17
|
+
"usage": {"primary": {"usedPercent": 10, "windowMinutes": 300}},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _backdate(provider: str, seconds: float) -> None:
|
|
22
|
+
path = throttle_dir() / f"{provider}.json"
|
|
23
|
+
state = json.loads(path.read_text())
|
|
24
|
+
state["queried_at"] = (utcnow() - timedelta(seconds=seconds)).isoformat()
|
|
25
|
+
path.write_text(json.dumps(state))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_antigravity_defaults_to_fifteen_minutes():
|
|
29
|
+
assert DEFAULT_CONFIG["query_min_interval"]["antigravity"] == 900
|
|
30
|
+
assert min_intervals({}) == {"antigravity": 900.0}
|
|
31
|
+
assert min_intervals(DEFAULT_CONFIG) == {"antigravity": 900.0}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_config_can_change_or_disable_a_limit():
|
|
35
|
+
assert min_intervals({"query_min_interval": {"antigravity": 1800}}) == {"antigravity": 1800.0}
|
|
36
|
+
assert min_intervals({"query_min_interval": {"antigravity": 0}}) == {}
|
|
37
|
+
assert min_intervals({"query_min_interval": {"Grok": 60}}) == {"antigravity": 900.0, "grok": 60.0}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_validate_config_checks_query_min_interval():
|
|
41
|
+
assert not [i for i in validate_config({"query_min_interval": {"antigravity": 900}}) if "query_min" in i]
|
|
42
|
+
issues = validate_config({"query_min_interval": {"antigravity": "soon", "grok": -1}})
|
|
43
|
+
assert any("query_min_interval.antigravity must be a number" in i for i in issues)
|
|
44
|
+
assert any("query_min_interval.grok must not be negative" in i for i in issues)
|
|
45
|
+
assert any("must be a provider -> seconds mapping" in i for i in validate_config({"query_min_interval": 5}))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_gate_allows_one_query_per_interval_and_reuses_the_payload():
|
|
49
|
+
with QueryGate("antigravity", min_interval=900, wait=0) as gate:
|
|
50
|
+
assert gate.allowed
|
|
51
|
+
gate.record("codexbar", [_AGY_ROW])
|
|
52
|
+
|
|
53
|
+
with QueryGate("antigravity", min_interval=900, wait=0) as gate:
|
|
54
|
+
assert not gate.allowed
|
|
55
|
+
assert gate.reuse("codexbar") == [_AGY_ROW]
|
|
56
|
+
assert "at most every 15m" in gate.describe()
|
|
57
|
+
assert "via codexbar" in gate.describe()
|
|
58
|
+
|
|
59
|
+
_backdate("antigravity", 901)
|
|
60
|
+
with QueryGate("antigravity", min_interval=900, wait=0) as gate:
|
|
61
|
+
assert gate.allowed
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_gate_held_by_another_process_counts_as_throttled():
|
|
65
|
+
with QueryGate("antigravity", min_interval=900, wait=0) as first:
|
|
66
|
+
assert first.allowed
|
|
67
|
+
with QueryGate("antigravity", min_interval=900, wait=0) as second:
|
|
68
|
+
assert not second.allowed
|
|
69
|
+
assert "another aiuse process" in second.describe()
|
|
70
|
+
assert isinstance(second.reuse("codexbar"), CollectorError)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_codexbar_queries_antigravity_once_per_interval(monkeypatch):
|
|
74
|
+
calls: list[str | None] = []
|
|
75
|
+
|
|
76
|
+
def fake_query(provider_arg, *, timeout=45.0):
|
|
77
|
+
calls.append(provider_arg)
|
|
78
|
+
return [dict(_AGY_ROW, provider=provider_arg)]
|
|
79
|
+
|
|
80
|
+
monkeypatch.setattr(codexbar, "_query_provider", fake_query)
|
|
81
|
+
limits = {"antigravity": 900.0}
|
|
82
|
+
|
|
83
|
+
first_notes: dict[str, str] = {}
|
|
84
|
+
first = codexbar._query_providers(["codex", "antigravity"], min_intervals=limits, reuse_notes=first_notes)
|
|
85
|
+
second_notes: dict[str, str] = {}
|
|
86
|
+
second = codexbar._query_providers(["codex", "antigravity"], min_intervals=limits, reuse_notes=second_notes)
|
|
87
|
+
|
|
88
|
+
assert calls.count("antigravity") == 1
|
|
89
|
+
assert calls.count("codex") == 2
|
|
90
|
+
assert dict(second)["antigravity"] == dict(first)["antigravity"]
|
|
91
|
+
assert first_notes == {}
|
|
92
|
+
assert "reused CodexBar's result" in second_notes["antigravity"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_codexbar_reused_rows_carry_the_throttle_note(monkeypatch):
|
|
96
|
+
monkeypatch.setattr(codexbar, "which", lambda _cmd: "/usr/bin/codexbar")
|
|
97
|
+
monkeypatch.setattr(codexbar, "_query_provider", lambda provider_arg, *, timeout=45.0: [_AGY_ROW])
|
|
98
|
+
|
|
99
|
+
codexbar.collect_codexbar(providers="antigravity", min_intervals={"antigravity": 900.0})
|
|
100
|
+
accounts = codexbar.collect_codexbar(providers="antigravity", min_intervals={"antigravity": 900.0})
|
|
101
|
+
|
|
102
|
+
assert [a.provider for a in accounts] == ["antigravity"]
|
|
103
|
+
assert any("queried at most every 15m" in note for note in accounts[0].notes)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_a_failed_antigravity_query_still_counts(monkeypatch):
|
|
107
|
+
calls: list[str | None] = []
|
|
108
|
+
|
|
109
|
+
def failing_query(provider_arg, *, timeout=45.0):
|
|
110
|
+
calls.append(provider_arg)
|
|
111
|
+
return CollectorError("HTTP 429 RESOURCE_EXHAUSTED")
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr(codexbar, "_query_provider", failing_query)
|
|
114
|
+
limits = {"antigravity": 900.0}
|
|
115
|
+
codexbar._query_providers(["antigravity"], min_intervals=limits)
|
|
116
|
+
[(_, outcome)] = codexbar._query_providers(["antigravity"], min_intervals=limits)
|
|
117
|
+
|
|
118
|
+
assert calls == ["antigravity"]
|
|
119
|
+
assert isinstance(outcome, CollectorError)
|
|
120
|
+
assert "429" in str(outcome) and "next allowed in" in str(outcome)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_codexbar_without_limits_is_unchanged(monkeypatch):
|
|
124
|
+
calls: list[str | None] = []
|
|
125
|
+
monkeypatch.setattr(
|
|
126
|
+
codexbar, "_query_provider", lambda provider_arg, *, timeout=45.0: calls.append(provider_arg) or [_AGY_ROW]
|
|
127
|
+
)
|
|
128
|
+
codexbar._query_providers(["antigravity"])
|
|
129
|
+
codexbar._query_providers(["antigravity"])
|
|
130
|
+
assert calls == ["antigravity", "antigravity"]
|
|
131
|
+
assert not (throttle_dir() / "antigravity.json").exists()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_openusage_withholds_force_while_antigravity_is_throttled(monkeypatch):
|
|
135
|
+
forced: list[bool] = []
|
|
136
|
+
payload = {
|
|
137
|
+
"schema": "openusage.limits.v1",
|
|
138
|
+
"providers": {
|
|
139
|
+
"antigravity": {
|
|
140
|
+
"displayName": "Antigravity",
|
|
141
|
+
"resources": {"geminiSession": {"kind": "consumption", "unit": "percent", "used": 10}},
|
|
142
|
+
},
|
|
143
|
+
"codex": {
|
|
144
|
+
"displayName": "Codex",
|
|
145
|
+
"resources": {"session": {"kind": "consumption", "unit": "percent", "used": 5}},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def fake_fetch(*, timeout, base_url, force_refresh, try_launch_app):
|
|
151
|
+
forced.append(force_refresh)
|
|
152
|
+
return payload, "cli"
|
|
153
|
+
|
|
154
|
+
monkeypatch.setattr(openusage, "which", lambda _cmd: "/usr/local/bin/openusage")
|
|
155
|
+
monkeypatch.setattr(openusage, "_fetch_limits", fake_fetch)
|
|
156
|
+
limits = {"antigravity": 900.0}
|
|
157
|
+
|
|
158
|
+
openusage.collect_openusage_ai(min_intervals=limits)
|
|
159
|
+
accounts = openusage.collect_openusage_ai(min_intervals=limits)
|
|
160
|
+
|
|
161
|
+
assert forced == [True, False]
|
|
162
|
+
by_provider = {a.provider: a for a in accounts}
|
|
163
|
+
assert any("refresh not forced" in n for n in by_provider["antigravity"].notes)
|
|
164
|
+
assert not any("refresh not forced" in n for n in by_provider["codex"].notes)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_openusage_force_without_antigravity_does_not_spend_the_slot(monkeypatch):
|
|
168
|
+
payload = {
|
|
169
|
+
"providers": {
|
|
170
|
+
"codex": {
|
|
171
|
+
"displayName": "Codex",
|
|
172
|
+
"resources": {"session": {"kind": "consumption", "unit": "percent", "used": 5}},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
monkeypatch.setattr(openusage, "which", lambda _cmd: "/usr/local/bin/openusage")
|
|
177
|
+
monkeypatch.setattr(openusage, "_fetch_limits", lambda **_kw: (payload, "cli"))
|
|
178
|
+
openusage.collect_openusage_ai(min_intervals={"antigravity": 900.0})
|
|
179
|
+
|
|
180
|
+
with QueryGate("antigravity", min_interval=900, wait=0) as gate:
|
|
181
|
+
assert gate.allowed
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|