aiuse 3.3.2__tar.gz → 3.3.4__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 (105) hide show
  1. {aiuse-3.3.2/src/aiuse.egg-info → aiuse-3.3.4}/PKG-INFO +1 -2
  2. {aiuse-3.3.2 → aiuse-3.3.4}/README.md +0 -1
  3. {aiuse-3.3.2 → aiuse-3.3.4}/pyproject.toml +1 -1
  4. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/__init__.py +1 -1
  5. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/history.py +3 -1
  6. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/selfdescribe.py +25 -1
  7. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/cli.py +5 -2
  8. aiuse-3.3.4/src/aiuse/client_limits.py +159 -0
  9. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/__init__.py +2 -0
  10. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/bailian.py +55 -2
  11. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/base.py +5 -1
  12. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/codexbar.py +71 -19
  13. aiuse-3.3.4/src/aiuse/collectors/deepseek.py +155 -0
  14. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/runner.py +59 -1
  15. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/throttle.py +151 -4
  16. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/config.py +33 -0
  17. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/models.py +7 -4
  18. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/report.py +16 -4
  19. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/secretspec.py +5 -0
  20. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/serve.py +6 -1
  21. {aiuse-3.3.2 → aiuse-3.3.4/src/aiuse.egg-info}/PKG-INFO +1 -2
  22. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/SOURCES.txt +5 -0
  23. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_bailian.py +72 -1
  24. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_claude_sublimits.py +15 -1
  25. aiuse-3.3.4/tests/test_client_limits.py +141 -0
  26. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_codexbar_parse.py +25 -0
  27. aiuse-3.3.4/tests/test_codexbar_timeouts.py +130 -0
  28. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_config.py +12 -0
  29. aiuse-3.3.4/tests/test_deepseek.py +128 -0
  30. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_models_cli_binary.py +0 -1
  31. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_query_throttle.py +80 -0
  32. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_report.py +1 -2
  33. {aiuse-3.3.2 → aiuse-3.3.4}/LICENSE +0 -0
  34. {aiuse-3.3.2 → aiuse-3.3.4}/setup.cfg +0 -0
  35. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/__main__.py +0 -0
  36. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/agent_notes.py +0 -0
  37. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/ai_stub.py +0 -0
  38. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/__init__.py +0 -0
  39. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/local_runtimes.py +0 -0
  40. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/pace.py +0 -0
  41. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/suggest.py +0 -0
  42. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/use_or_lose.py +0 -0
  43. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/attribute.py +0 -0
  44. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/chat_format.py +0 -0
  45. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/caut.py +0 -0
  46. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/clinepass.py +0 -0
  47. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/cswap.py +0 -0
  48. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/grok.py +0 -0
  49. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/hermes.py +0 -0
  50. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/muse.py +0 -0
  51. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/opencode_go.py +0 -0
  52. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/opencode_zen.py +0 -0
  53. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/openrouter.py +0 -0
  54. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/openusage.py +0 -0
  55. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/openusage_sh.py +0 -0
  56. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/qwencloud.py +0 -0
  57. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/tokscale.py +0 -0
  58. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/credentials.py +0 -0
  59. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/ledger.py +0 -0
  60. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/macos_trust.py +0 -0
  61. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/sampler.py +0 -0
  62. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/tty.py +0 -0
  63. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/tui/__init__.py +0 -0
  64. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/tui/app.py +0 -0
  65. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/tui/builders.py +0 -0
  66. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/watch.py +0 -0
  67. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/dependency_links.txt +0 -0
  68. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/entry_points.txt +0 -0
  69. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/requires.txt +0 -0
  70. {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/top_level.txt +0 -0
  71. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_attribution.py +0 -0
  72. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_base_run_json.py +0 -0
  73. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_caut_parse.py +0 -0
  74. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_chat_format.py +0 -0
  75. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_cli.py +0 -0
  76. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_clinepass.py +0 -0
  77. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_credentials.py +0 -0
  78. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_cswap_parse.py +0 -0
  79. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_grok.py +0 -0
  80. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_hermes_parse.py +0 -0
  81. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_history.py +0 -0
  82. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_install_deps.py +0 -0
  83. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_local_runtimes.py +0 -0
  84. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_macos_trust.py +0 -0
  85. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_models_pace.py +0 -0
  86. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_muse.py +0 -0
  87. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_opencode_go.py +0 -0
  88. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_opencode_zen.py +0 -0
  89. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_openrouter.py +0 -0
  90. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_openusage_cli_resolution.py +0 -0
  91. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_openusage_parse.py +0 -0
  92. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_pace.py +0 -0
  93. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_qwencloud.py +0 -0
  94. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_release_script.py +0 -0
  95. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_runner_consolidation.py +0 -0
  96. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_selfdescribe.py +0 -0
  97. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_serve.py +0 -0
  98. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_shared_quota_semantics.py +0 -0
  99. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_snapshots_persistence.py +0 -0
  100. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_suggest.py +0 -0
  101. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_tokscale_parse.py +0 -0
  102. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_tty.py +0 -0
  103. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_tui.py +0 -0
  104. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_use_or_lose.py +0 -0
  105. {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.3.2
3
+ Version: 3.3.4
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
@@ -74,7 +74,6 @@ for the actual agent CLI whose quota it tracks:
74
74
  | Muse (Meta) | `muse` |
75
75
  | Devin | `devin` |
76
76
  | ClinePass | `cline` |
77
- | Hyper | `crush` |
78
77
  | DeepSeek, OpenRouter | no CLI — per-token $ API only |
79
78
 
80
79
  `aiuse --json` exposes this same mapping per account row as `cli_binary`
@@ -33,7 +33,6 @@ for the actual agent CLI whose quota it tracks:
33
33
  | Muse (Meta) | `muse` |
34
34
  | Devin | `devin` |
35
35
  | ClinePass | `cline` |
36
- | Hyper | `crush` |
37
36
  | DeepSeek, OpenRouter | no CLI — per-token $ API only |
38
37
 
39
38
  `aiuse --json` exposes this same mapping per account row as `cli_binary`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiuse"
7
- version = "3.3.2"
7
+ version = "3.3.4"
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.3.2"
3
+ __version__ = "3.3.4"
@@ -128,7 +128,9 @@ def save_snapshot(
128
128
  # Self-describing at rest: windows carry state/pool_family and accounts
129
129
  # carry usable_now/binding_window/available_at, so even a naive reader of
130
130
  # the cache file cannot misread a consumed share as free headroom.
131
- snap_dict = enrich_snapshot(snapshot.to_dict(), notes=load_active_notes())
131
+ from aiuse.client_limits import load_client_limits
132
+
133
+ snap_dict = enrich_snapshot(snapshot.to_dict(), notes=load_active_notes(), client_limits=load_client_limits())
132
134
  payload = {
133
135
  "schema_version": SCHEMA_VERSION,
134
136
  "collection_id": f"{ts}-{os.getpid()}",
@@ -48,6 +48,7 @@ SEMANTICS: dict[str, str] = {
48
48
  "fresh": "age_seconds <= fresh threshold (default 1500s); when false, re-collect before trusting ok states",
49
49
  "summary_lines": "one human line per pool; always shows used AND left, never a bare percentage",
50
50
  "agent_notes": "exhaustion overrides reported by agents (source: agent-reported); expire at their reset time, after which a live collector reading wins",
51
+ "client_limits": "per-client rate limits the quota windows cannot show, read passively (e.g. agy CLI 429s from its own logs); state limited means that client is failing now while another client (agy ACP) may still work; usable_now stays quota-based",
51
52
  "excluded": "--available only: usable pools the operator ruled out (analysis.excluded_pools), with the reason; never route to them",
52
53
  "disabled_services": "operator-disabled providers (config [disabled_services]) with the reason; no rows are collected for them — do not route to or spend them until the operator removes the entry",
53
54
  }
@@ -221,13 +222,25 @@ def enrich_snapshot(
221
222
  *,
222
223
  now: datetime | None = None,
223
224
  notes: list[Mapping[str, Any]] | None = None,
225
+ client_limits: Mapping[str, list[dict[str, Any]]] | None = None,
224
226
  ) -> dict[str, Any]:
225
- """Enrich accounts in place and attach summary_lines / semantics."""
227
+ """Enrich accounts in place and attach summary_lines / semantics.
228
+
229
+ ``client_limits`` (provider -> entries, from aiuse.client_limits) replaces
230
+ each matching account's ``client_limits``; read-time evidence wins over
231
+ whatever a cached snapshot carried.
232
+ """
226
233
  now = now or utcnow()
227
234
  accounts = [a for a in (snap.get("accounts") or []) if isinstance(a, dict)]
228
235
  collected = snap.get("collected_at")
229
236
  for account in accounts:
230
237
  enrich_account(account, collected_at=collected, now=now, notes=notes)
238
+ if client_limits is not None:
239
+ limits = client_limits.get(str(account.get("provider") or ""))
240
+ if limits:
241
+ account["client_limits"] = [dict(entry) for entry in limits]
242
+ else:
243
+ account.pop("client_limits", None)
231
244
  snap["accounts"] = accounts
232
245
  snap["summary_lines"] = summary_lines(pool_entries(snap))
233
246
  snap["semantics"] = dict(SEMANTICS)
@@ -322,6 +335,7 @@ def pool_entries(snap: Mapping[str, Any]) -> list[dict[str, Any]]:
322
335
  "available_at": min(resets).isoformat() if resets else None,
323
336
  "models_hint": hints.get(family),
324
337
  "age_seconds": account.get("age_seconds"),
338
+ **({"client_limits": account["client_limits"]} if account.get("client_limits") else {}),
325
339
  "windows": windows,
326
340
  }
327
341
  )
@@ -382,6 +396,16 @@ def summary_line(pool: Mapping[str, Any]) -> str:
382
396
  seg += " [agent-reported]"
383
397
  segments.append(seg)
384
398
  line = f"{lead}: " + "; ".join(segments)
399
+ for limit in pool.get("client_limits") or []:
400
+ if isinstance(limit, dict) and limit.get("state") == "limited":
401
+ client = limit.get("cli_binary") or limit.get("client") or "client"
402
+ line += (
403
+ f" [{client} {limit.get('client') or 'client'} rate-limited: 429 x{limit.get('failed_attempts', '?')}"
404
+ )
405
+ when = parse_dt(limit.get("last_limited_at"))
406
+ if when is not None:
407
+ line += f", last {max(0, int((utcnow() - when).total_seconds() // 60))}m ago"
408
+ line += "; ACP may still work]"
385
409
  if pool.get("usable_now") is False:
386
410
  line += " -> NOT usable now"
387
411
  elif all(str(w.get("state")) == "unknown" for w in pool.get("windows") or []):
@@ -530,8 +530,9 @@ def _main_inner(argv: list[str] | None = None) -> int:
530
530
  insights = history_insights(snapshot, analysis_cfg=analysis_cfg)
531
531
  from aiuse.agent_notes import load_active_notes
532
532
  from aiuse.analysis.selfdescribe import SCHEMA_VERSION, enrich_snapshot
533
+ from aiuse.client_limits import load_client_limits
533
534
 
534
- snap_dict = enrich_snapshot(snapshot.to_dict(), notes=load_active_notes())
535
+ snap_dict = enrich_snapshot(snapshot.to_dict(), notes=load_active_notes(), client_limits=load_client_limits(config))
535
536
  payload = {
536
537
  "schema_version": SCHEMA_VERSION,
537
538
  "contract_url": "https://github.com/djbclark/aiuse/blob/main/docs/json-contract.md",
@@ -1092,7 +1093,9 @@ def _run_available(args: argparse.Namespace) -> int:
1092
1093
  snap_dict = snapshot.to_dict()
1093
1094
  source = "live"
1094
1095
 
1095
- enrich_snapshot(snap_dict, notes=notes)
1096
+ from aiuse.client_limits import load_client_limits
1097
+
1098
+ enrich_snapshot(snap_dict, notes=notes, client_limits=load_client_limits(config))
1096
1099
  fresh = freshness(snap_dict.get("collected_at"), threshold_seconds=threshold)
1097
1100
  # [disabled_services] is the stronger, overall disable: rows are normally
1098
1101
  # filtered at collection, but a cache written by an older build (or with
@@ -0,0 +1,159 @@
1
+ """Per-client rate limits that quota windows cannot show (issue #33).
2
+
3
+ agy (Antigravity) has two clients on one account: the ``agy`` CLI and the ACP
4
+ server (``acp-run agy``). On 2026-10-03 every CLI generation request answered
5
+ ``RESOURCE_EXHAUSTED (code 429)`` for hours while both quota windows showed
6
+ headroom and the ACP server kept working. aiuse reads quota, so it reported
7
+ the pool as fully usable.
8
+
9
+ This module is a **passive** signal: it reads the agy CLI's own log files and
10
+ never sends a request (no probe, no agy launch). The CLI retries a failed
11
+ generation in-process and logs each attempt::
12
+
13
+ I1005 06:45:54.279416 2773 run.go:395] Run: attempt 1 failed (RESOURCE_EXHAUSTED (code 429): ...), retrying in 4s
14
+
15
+ A 429 attempt within the lookback window (default 60 minutes) marks the CLI
16
+ client ``limited``. The quota numbers and ``usable_now`` are left alone: the
17
+ quota is real and the ACP client may still serve it, so hiding the pool would
18
+ also hide a working route. The signal rides along as ``client_limits`` on the
19
+ antigravity account and its routing entries, and in their summary lines.
20
+
21
+ The logs carry no positive "generation succeeded" marker, so a lockout clears
22
+ only by ageing out of the lookback window.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import re
29
+ from datetime import datetime, timedelta, timezone
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from aiuse.models import utcnow
34
+
35
+ DEFAULT_LOOKBACK_MINUTES = 60.0
36
+ # Never read more than this many recent log files per scan (each is ~200 lines).
37
+ _MAX_FILES = 50
38
+
39
+ _LINE = re.compile(
40
+ r"^[IWEF](?P<month>\d\d)(?P<day>\d\d) (?P<clock>\d\d:\d\d:\d\d(?:\.\d+)?)\s+\d+\s+\S+\]\s+"
41
+ r"Run: attempt (?P<attempt>\d+) failed \((?P<error>RESOURCE_EXHAUSTED[^)]*\))"
42
+ )
43
+ _FILE_DATE = re.compile(r"cli-(?P<year>\d{4})(?P<month>\d\d)(?P<day>\d\d)_")
44
+
45
+
46
+ def agy_cli_log_dir() -> Path:
47
+ return Path.home() / ".gemini" / "antigravity-cli" / "log"
48
+
49
+
50
+ def lookback_minutes(config: dict[str, Any] | None) -> float:
51
+ """``analysis.agy_cli_lockout_minutes`` (0 disables the scan)."""
52
+ analysis = (config or {}).get("analysis")
53
+ value = analysis.get("agy_cli_lockout_minutes") if isinstance(analysis, dict) else None
54
+ if value is None:
55
+ return DEFAULT_LOOKBACK_MINUTES
56
+ try:
57
+ return max(0.0, float(value))
58
+ except (TypeError, ValueError):
59
+ return DEFAULT_LOOKBACK_MINUTES
60
+
61
+
62
+ def _line_time(match: re.Match[str], file_year: int, file_month: int) -> datetime | None:
63
+ """glog stamps are local time with no year; the file name supplies the year."""
64
+ month, day = int(match["month"]), int(match["day"])
65
+ year = file_year + 1 if month < file_month else file_year # Dec -> Jan rollover
66
+ clock = match["clock"]
67
+ try:
68
+ naive = datetime.strptime(f"{year}-{month:02d}-{day:02d} {clock}", "%Y-%m-%d %H:%M:%S.%f")
69
+ except ValueError:
70
+ try:
71
+ naive = datetime.strptime(f"{year}-{month:02d}-{day:02d} {clock}", "%Y-%m-%d %H:%M:%S")
72
+ except ValueError:
73
+ return None
74
+ return naive.astimezone().astimezone(timezone.utc)
75
+
76
+
77
+ def scan_agy_cli_lockout(
78
+ *,
79
+ lookback: timedelta = timedelta(minutes=DEFAULT_LOOKBACK_MINUTES),
80
+ now: datetime | None = None,
81
+ log_dir: Path | None = None,
82
+ ) -> dict[str, Any] | None:
83
+ """The agy CLI's recent 429 evidence as one ``client_limits`` entry, or None."""
84
+ if lookback.total_seconds() <= 0:
85
+ return None
86
+ now = now or utcnow()
87
+ directory = log_dir if log_dir is not None else agy_cli_log_dir()
88
+ since = now - lookback
89
+ try:
90
+ entries = [entry for entry in os.scandir(directory) if entry.name.startswith("cli-") and entry.is_file()]
91
+ except OSError:
92
+ return None
93
+ recent: list[tuple[float, Path]] = []
94
+ for entry in entries:
95
+ try:
96
+ mtime = entry.stat().st_mtime
97
+ except OSError:
98
+ continue
99
+ if mtime >= since.timestamp():
100
+ recent.append((mtime, Path(entry.path)))
101
+ recent.sort(reverse=True)
102
+
103
+ failed_attempts = 0
104
+ runs: set[str] = set()
105
+ last_at: datetime | None = None
106
+ last_error = ""
107
+ evidence: Path | None = None
108
+ for _mtime, path in recent[:_MAX_FILES]:
109
+ named = _FILE_DATE.search(path.name)
110
+ file_year = int(named["year"]) if named else now.year
111
+ file_month = int(named["month"]) if named else now.month
112
+ try:
113
+ text = path.read_text(encoding="utf-8", errors="replace")
114
+ except OSError:
115
+ continue
116
+ for line in text.splitlines():
117
+ if "RESOURCE_EXHAUSTED" not in line:
118
+ continue
119
+ match = _LINE.match(line)
120
+ if match is None:
121
+ continue
122
+ at = _line_time(match, file_year, file_month)
123
+ if at is None or at < since or at > now + timedelta(minutes=5):
124
+ continue
125
+ failed_attempts += 1
126
+ runs.add(path.name)
127
+ if last_at is None or at > last_at:
128
+ last_at, last_error, evidence = at, match["error"], path
129
+ if last_at is None:
130
+ return None
131
+ minutes_ago = max(0, int((now - last_at).total_seconds() // 60))
132
+ run_word = "run" if len(runs) == 1 else "runs"
133
+ return {
134
+ "client": "cli",
135
+ "cli_binary": "agy",
136
+ "state": "limited",
137
+ "source": "agy-cli-log",
138
+ "last_limited_at": last_at.isoformat(),
139
+ "failed_attempts": failed_attempts,
140
+ "runs": len(runs),
141
+ "lookback_minutes": round(lookback.total_seconds() / 60.0, 1),
142
+ "error": last_error,
143
+ "evidence": str(evidence) if evidence else None,
144
+ "message": (
145
+ f"agy CLI got RESOURCE_EXHAUSTED (429) on {failed_attempts} attempts in {len(runs)} {run_word}, "
146
+ f"last {minutes_ago}m ago, although the quota windows may show headroom; "
147
+ "the ACP route (acp-run agy) is a separate client and may still work"
148
+ ),
149
+ }
150
+
151
+
152
+ def load_client_limits(config: dict[str, Any] | None = None) -> dict[str, list[dict[str, Any]]]:
153
+ """Provider -> active per-client limits, read passively at report time."""
154
+ minutes = lookback_minutes(config)
155
+ limits: dict[str, list[dict[str, Any]]] = {}
156
+ agy = scan_agy_cli_lockout(lookback=timedelta(minutes=minutes)) if minutes > 0 else None
157
+ if agy is not None:
158
+ limits["antigravity"] = [agy]
159
+ return limits
@@ -5,6 +5,7 @@ from .caut import collect_caut
5
5
  from .clinepass import collect_clinepass
6
6
  from .codexbar import collect_codexbar
7
7
  from .cswap import collect_cswap
8
+ from .deepseek import collect_deepseek
8
9
  from .hermes import collect_hermes
9
10
  from .muse import collect_muse
10
11
  from .opencode_go import collect_opencode_go
@@ -27,6 +28,7 @@ __all__ = [
27
28
  "collect_openusage_sh",
28
29
  "collect_opencode_go",
29
30
  "collect_opencode_zen",
31
+ "collect_deepseek",
30
32
  "collect_openrouter",
31
33
  "collect_qwencloud",
32
34
  "collect_bailian",
@@ -21,7 +21,9 @@ the ``qwencloud`` collector reads (canonical providers ``alibaba`` vs
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import json
24
25
  from datetime import datetime, timezone
26
+ from pathlib import Path
25
27
  from typing import Any
26
28
 
27
29
  from aiuse.models import AccountUsage, BillingKind, QuotaWindow, coerce_float
@@ -51,10 +53,16 @@ def collect_bailian(*, timeout: float = 45.0) -> list[AccountUsage]:
51
53
  except CollectorError as exc:
52
54
  message = str(exc)
53
55
  if _looks_unauthenticated(message):
54
- # No console login on this machine: quiet, like muse/clinepass.
55
- return []
56
+ # Both subcommands share the one console session: stop here.
57
+ return _unauthenticated(message)
56
58
  errors.append(f"{subcommand}: {message}")
57
59
  continue
60
+ payload_error = _payload_error(payload)
61
+ if payload_error is not None:
62
+ if _looks_unauthenticated(payload_error):
63
+ return _unauthenticated(payload_error)
64
+ errors.append(f"{subcommand}: {payload_error}")
65
+ continue
58
66
  account = _account_from_payload(payload, plan=plan)
59
67
  if account is not None:
60
68
  accounts.append(account)
@@ -112,4 +120,49 @@ def _looks_unauthenticated(message: str) -> bool:
112
120
  or "not authenticated" in lowered
113
121
  or "auth login" in lowered
114
122
  or "login required" in lowered
123
+ or "not logged in" in lowered
124
+ or "session" in lowered
125
+ and "expired" in lowered
115
126
  )
127
+
128
+
129
+ LOGIN_HINT = "run `bl auth login --console` to sign in again"
130
+
131
+
132
+ def _config_path() -> Path:
133
+ return Path.home() / ".bailian" / "config.json"
134
+
135
+
136
+ def _unauthenticated(message: str) -> list[AccountUsage]:
137
+ """The console session is missing or expired (aiuse-oja).
138
+
139
+ On a machine where bl was never set up this stays quiet, like
140
+ muse/clinepass. Once ``~/.bailian/config.json`` exists the operator has
141
+ logged in before, so the session has expired: say so on one alibaba row.
142
+ That row outranks CodexBar's cookie-based alibaba errors in the source
143
+ priority, so the board shows the fix instead of an unrelated cookie
144
+ failure. bl is not re-run and nothing tries to log in.
145
+ """
146
+ if not _config_path().exists():
147
+ return []
148
+ expired = "expired" in message.lower() or "not logged in" in message.lower()
149
+ reason = "bl console session expired or not logged in" if expired else "bl console login missing"
150
+ return [
151
+ AccountUsage(
152
+ source="bailian",
153
+ provider="alibaba",
154
+ billing_kind=BillingKind.SUBSCRIPTION_WINDOW,
155
+ error=f"{reason}; {LOGIN_HINT}",
156
+ )
157
+ ]
158
+
159
+
160
+ def _payload_error(payload: Any) -> str | None:
161
+ """bl's ``{"error": {"message": ..., "hint": ...}}`` envelope as one line."""
162
+ if not isinstance(payload, dict) or "error" not in payload:
163
+ return None
164
+ error = payload.get("error")
165
+ if isinstance(error, dict):
166
+ parts = [str(error.get(key)).strip() for key in ("message", "hint") if error.get(key)]
167
+ return " ".join(parts) or json.dumps(error)
168
+ return str(error)
@@ -14,6 +14,10 @@ class CollectorError(RuntimeError):
14
14
  pass
15
15
 
16
16
 
17
+ class CollectorTimeout(CollectorError):
18
+ """A collector subprocess was killed at its timeout (it hung, not failed)."""
19
+
20
+
17
21
  # Keep in sync with ai.config.DEFAULT_SUBPROCESS_TIMEOUT (import avoided to
18
22
  # keep this module free of config load side effects for unit tests).
19
23
  DEFAULT_RUN_TIMEOUT = 45.0
@@ -118,7 +122,7 @@ def run_json(
118
122
  except FileNotFoundError as exc:
119
123
  raise CollectorError(f"command not found: {argv[0]}") from exc
120
124
  except subprocess.TimeoutExpired as exc:
121
- raise CollectorError(f"timed out after {timeout}s: {' '.join(argv)}") from exc
125
+ raise CollectorTimeout(f"timed out after {timeout}s: {' '.join(argv)}") from exc
122
126
 
123
127
  stdout = (proc.stdout or "").strip()
124
128
  stderr = (proc.stderr or "").strip()
@@ -12,6 +12,7 @@ from aiuse.models import (
12
12
  BillingKind,
13
13
  QuotaWindow,
14
14
  UsageCredits,
15
+ canonical_provider,
15
16
  classify_window_minutes,
16
17
  keep_copilot_report_window,
17
18
  parse_dt,
@@ -20,8 +21,8 @@ from aiuse.models import (
20
21
  from aiuse.models import coerce_float as _f
21
22
  from aiuse.models import coerce_int as _int_or_none
22
23
 
23
- from .base import CollectorError, run_json, which
24
- from .throttle import QueryGate
24
+ from .base import CollectorError, CollectorTimeout, run_json, which
25
+ from .throttle import QueryGate, TimeoutBackoff
25
26
 
26
27
  # Providers that are typically pure prepaid / API balance (not use-or-lose monthly)
27
28
  PREPAID_HINTS = {
@@ -41,11 +42,23 @@ PREPAID_HINTS = {
41
42
  # unreasonable number of processes for a very long explicit --providers list.
42
43
  _MAX_CONCURRENT_PROVIDER_QUERIES = 16
43
44
 
44
- # CodexBar `auto` for these providers prefers a local heuristic that can diverge
45
- # from server-authoritative quotas. OpenCode Go's local path sums SQLite costs
46
- # against hardcoded $12/$30/$60 caps; the live billing page is the real limit.
47
- # Force `--source web` first, then fall back to auto if web is unavailable.
48
- _WEB_PREFERRED_PROVIDERS = frozenset({"opencodego"})
45
+ # After a provider's query hangs until its timeout, skip it for this long
46
+ # (doubling per further consecutive timeout, capped at 12x). One hanging
47
+ # provider otherwise holds every aiuse run, including each scheduled sample,
48
+ # at the full CodexBar timeout. [collectors.codexbar] timeout_backoff overrides;
49
+ # 0 disables. See aiuse-e9d and docs/collector-concurrency.md.
50
+ DEFAULT_TIMEOUT_BACKOFF_SECONDS = 1800.0
51
+
52
+ # Providers queried with a specific CodexBar `--source` first, falling back to
53
+ # `auto` when that source returns nothing usable.
54
+ #
55
+ # - opencodego -> web: `auto` prefers a local heuristic that sums SQLite costs
56
+ # against hardcoded $12/$30/$60 caps; the live billing page is the real limit.
57
+ # - antigravity -> oauth: `auto` reads the agy CLI, which spawns agy three times
58
+ # per probe and boots its language server each time (issue #34). `oauth`
59
+ # reads stored Google auth and spawns nothing; when it has no credentials
60
+ # the CLI path still runs, gated by query_min_interval.
61
+ _PREFERRED_SOURCES: dict[str, str] = {"opencodego": "web", "antigravity": "oauth"}
49
62
 
50
63
  _SLOT_LABELS: dict[str, tuple[str, str, str]] = {
51
64
  # CodexBar maps Copilot primary→premium, secondary→chat (no tertiary /
@@ -109,6 +122,9 @@ def collect_codexbar(
109
122
  timeout: float = 45.0,
110
123
  discovery_timeout: float | None = None,
111
124
  min_intervals: dict[str, float] | None = None,
125
+ skip_providers: frozenset[str] | None = None,
126
+ provider_timeouts: dict[str, float] | None = None,
127
+ timeout_backoff: float | None = DEFAULT_TIMEOUT_BACKOFF_SECONDS,
112
128
  ) -> list[AccountUsage]:
113
129
  if not which("codexbar"):
114
130
  raise CollectorError("codexbar not found on PATH")
@@ -124,12 +140,26 @@ def collect_codexbar(
124
140
  if discovered:
125
141
  provider_list = discovered
126
142
 
143
+ if skip_providers:
144
+ # [disabled_services]: never spawn a query (or surface its timeout) for a
145
+ # provider the operator turned off, even if CodexBar still has it enabled.
146
+ kept = [p for p in provider_list if p is None or canonical_provider(p) not in skip_providers]
147
+ if not kept:
148
+ return []
149
+ provider_list = kept
150
+
127
151
  accounts: list[AccountUsage] = []
128
152
  errors: list[str] = []
129
153
  reuse_notes: dict[str, str] = {}
130
154
 
155
+ backoff = TimeoutBackoff("codexbar", base=timeout_backoff) if timeout_backoff else None
131
156
  for provider_arg, outcome in _query_providers(
132
- provider_list, timeout=timeout, min_intervals=min_intervals, reuse_notes=reuse_notes
157
+ provider_list,
158
+ timeout=timeout,
159
+ min_intervals=min_intervals,
160
+ reuse_notes=reuse_notes,
161
+ provider_timeouts=provider_timeouts,
162
+ backoff=backoff,
133
163
  ):
134
164
  if isinstance(outcome, CollectorError):
135
165
  name = provider_arg or "enabled providers"
@@ -212,6 +242,8 @@ def _query_providers(
212
242
  timeout: float = 45.0,
213
243
  min_intervals: dict[str, float] | None = None,
214
244
  reuse_notes: dict[str, str] | None = None,
245
+ provider_timeouts: dict[str, float] | None = None,
246
+ backoff: TimeoutBackoff | None = None,
215
247
  ) -> list[tuple[str | None, Any]]:
216
248
  """Run one `codexbar usage` call per entry in provider_list, concurrently.
217
249
 
@@ -224,15 +256,35 @@ def _query_providers(
224
256
  Providers listed in ``min_intervals`` go through a cross-process
225
257
  :class:`QueryGate`; when one is throttled its last stored payload is
226
258
  returned instead and ``reuse_notes[provider]`` explains why.
259
+
260
+ ``provider_timeouts`` overrides ``timeout`` per provider. With a
261
+ ``backoff``, a provider whose last query hung is skipped (its error says
262
+ until when) and a fresh hang is recorded for the next run.
227
263
  """
228
264
  deduped: list[str | None] = list(dict.fromkeys(provider_list))
229
265
  intervals = min_intervals or {}
266
+ per_provider = {str(k).strip().lower(): float(v) for k, v in (provider_timeouts or {}).items()}
230
267
  notes = reuse_notes if reuse_notes is not None else {}
231
268
 
232
269
  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)
270
+ if provider_arg is None:
271
+ return _query_provider(None, timeout=timeout)
272
+ provider = provider_arg.lower()
273
+ provider_timeout = per_provider.get(provider, timeout)
274
+ if backoff is not None:
275
+ reason = backoff.blocked(provider)
276
+ if reason is not None:
277
+ return CollectorError(reason)
278
+ if intervals.get(provider, 0) > 0:
279
+ outcome = _query_provider_gated(provider_arg, intervals[provider], timeout=provider_timeout, notes=notes)
280
+ else:
281
+ outcome = _query_provider(provider_arg, timeout=provider_timeout)
282
+ if backoff is not None:
283
+ if isinstance(outcome, CollectorTimeout):
284
+ backoff.record_timeout(provider, provider_timeout)
285
+ elif provider_arg not in notes: # a live answer, not a throttled reuse
286
+ backoff.clear(provider)
287
+ return outcome
236
288
 
237
289
  if len(deduped) <= 1:
238
290
  return [(provider_arg, run(provider_arg)) for provider_arg in deduped]
@@ -257,7 +309,7 @@ def _query_provider_gated(
257
309
  with QueryGate(provider, min_interval=min_interval, wait=timeout + 15.0) as gate:
258
310
  if gate.allowed:
259
311
  outcome = _query_provider(provider_arg, timeout=timeout)
260
- gate.record("codexbar", outcome)
312
+ gate.record("codexbar", outcome, usable=_usable_usage_payload(outcome))
261
313
  return outcome
262
314
  reused_at = gate.reused_from("codexbar")
263
315
  if reused_at is not None:
@@ -272,13 +324,13 @@ def _query_provider(provider_arg: str | None, *, timeout: float = 45.0) -> Any:
272
324
  if provider_arg is not None:
273
325
  argv.extend(["--provider", provider_arg])
274
326
 
275
- prefer_web = provider_arg is not None and provider_arg.lower() in _WEB_PREFERRED_PROVIDERS
276
- if prefer_web:
277
- web_outcome = _run_codexbar_usage([*argv, "--source", "web"], timeout=timeout)
278
- if _usable_usage_payload(web_outcome):
279
- return web_outcome
280
- # Web failed or returned only errors — fall through to CodexBar auto
281
- # (local heuristic) so the provider still appears in the report.
327
+ preferred = _PREFERRED_SOURCES.get(provider_arg.lower()) if provider_arg is not None else None
328
+ if preferred is not None:
329
+ preferred_outcome = _run_codexbar_usage([*argv, "--source", preferred], timeout=timeout)
330
+ if _usable_usage_payload(preferred_outcome):
331
+ return preferred_outcome
332
+ # The preferred source failed or returned only errors — fall through to
333
+ # CodexBar auto so the provider still appears in the report.
282
334
 
283
335
  return _run_codexbar_usage(argv, timeout=timeout)
284
336