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.
Files changed (98) hide show
  1. {aiuse-3.2.7/src/aiuse.egg-info → aiuse-3.2.8}/PKG-INFO +12 -1
  2. {aiuse-3.2.7 → aiuse-3.2.8}/README.md +11 -0
  3. {aiuse-3.2.7 → aiuse-3.2.8}/pyproject.toml +1 -1
  4. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/__init__.py +1 -1
  5. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/codexbar.py +45 -6
  6. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/openusage.py +54 -2
  7. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/runner.py +4 -0
  8. aiuse-3.2.8/src/aiuse/collectors/throttle.py +197 -0
  9. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/config.py +31 -1
  10. {aiuse-3.2.7 → aiuse-3.2.8/src/aiuse.egg-info}/PKG-INFO +12 -1
  11. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/SOURCES.txt +2 -0
  12. aiuse-3.2.8/tests/test_query_throttle.py +181 -0
  13. {aiuse-3.2.7 → aiuse-3.2.8}/LICENSE +0 -0
  14. {aiuse-3.2.7 → aiuse-3.2.8}/setup.cfg +0 -0
  15. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/__main__.py +0 -0
  16. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/agent_notes.py +0 -0
  17. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/ai_stub.py +0 -0
  18. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/__init__.py +0 -0
  19. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/history.py +0 -0
  20. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/local_runtimes.py +0 -0
  21. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/pace.py +0 -0
  22. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/selfdescribe.py +0 -0
  23. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/suggest.py +0 -0
  24. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/analysis/use_or_lose.py +0 -0
  25. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/attribute.py +0 -0
  26. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/chat_format.py +0 -0
  27. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/cli.py +0 -0
  28. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/__init__.py +0 -0
  29. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/bailian.py +0 -0
  30. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/base.py +0 -0
  31. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/caut.py +0 -0
  32. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/clinepass.py +0 -0
  33. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/cswap.py +0 -0
  34. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/grok.py +0 -0
  35. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/hermes.py +0 -0
  36. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/muse.py +0 -0
  37. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/opencode_go.py +0 -0
  38. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/opencode_zen.py +0 -0
  39. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/openrouter.py +0 -0
  40. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/openusage_sh.py +0 -0
  41. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/qwencloud.py +0 -0
  42. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/collectors/tokscale.py +0 -0
  43. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/credentials.py +0 -0
  44. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/ledger.py +0 -0
  45. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/macos_trust.py +0 -0
  46. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/models.py +0 -0
  47. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/report.py +0 -0
  48. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/sampler.py +0 -0
  49. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/secretspec.py +0 -0
  50. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/serve.py +0 -0
  51. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/tty.py +0 -0
  52. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/tui/__init__.py +0 -0
  53. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/tui/app.py +0 -0
  54. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/tui/builders.py +0 -0
  55. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse/watch.py +0 -0
  56. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/dependency_links.txt +0 -0
  57. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/entry_points.txt +0 -0
  58. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/requires.txt +0 -0
  59. {aiuse-3.2.7 → aiuse-3.2.8}/src/aiuse.egg-info/top_level.txt +0 -0
  60. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_attribution.py +0 -0
  61. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_bailian.py +0 -0
  62. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_base_run_json.py +0 -0
  63. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_caut_parse.py +0 -0
  64. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_chat_format.py +0 -0
  65. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_cli.py +0 -0
  66. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_clinepass.py +0 -0
  67. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_codexbar_parse.py +0 -0
  68. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_config.py +0 -0
  69. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_credentials.py +0 -0
  70. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_cswap_parse.py +0 -0
  71. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_grok.py +0 -0
  72. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_hermes_parse.py +0 -0
  73. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_history.py +0 -0
  74. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_install_deps.py +0 -0
  75. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_local_runtimes.py +0 -0
  76. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_macos_trust.py +0 -0
  77. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_models_cli_binary.py +0 -0
  78. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_models_pace.py +0 -0
  79. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_muse.py +0 -0
  80. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_opencode_go.py +0 -0
  81. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_opencode_zen.py +0 -0
  82. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_openrouter.py +0 -0
  83. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_openusage_parse.py +0 -0
  84. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_pace.py +0 -0
  85. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_qwencloud.py +0 -0
  86. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_release_script.py +0 -0
  87. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_report.py +0 -0
  88. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_runner_consolidation.py +0 -0
  89. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_selfdescribe.py +0 -0
  90. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_serve.py +0 -0
  91. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_shared_quota_semantics.py +0 -0
  92. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_snapshots_persistence.py +0 -0
  93. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_suggest.py +0 -0
  94. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_tokscale_parse.py +0 -0
  95. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_tty.py +0 -0
  96. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_tui.py +0 -0
  97. {aiuse-3.2.7 → aiuse-3.2.8}/tests/test_use_or_lose.py +0 -0
  98. {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.7
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
@@ -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.8"
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.8"
@@ -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).
@@ -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
  )
@@ -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
- {"timeouts", "analysis", "plans", "collectors", "account_aliases", "macos", "sampling", "attribution"}
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.7
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