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.
- {aiuse-3.3.2/src/aiuse.egg-info → aiuse-3.3.4}/PKG-INFO +1 -2
- {aiuse-3.3.2 → aiuse-3.3.4}/README.md +0 -1
- {aiuse-3.3.2 → aiuse-3.3.4}/pyproject.toml +1 -1
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/__init__.py +1 -1
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/history.py +3 -1
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/selfdescribe.py +25 -1
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/cli.py +5 -2
- aiuse-3.3.4/src/aiuse/client_limits.py +159 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/__init__.py +2 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/bailian.py +55 -2
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/base.py +5 -1
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/codexbar.py +71 -19
- aiuse-3.3.4/src/aiuse/collectors/deepseek.py +155 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/runner.py +59 -1
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/throttle.py +151 -4
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/config.py +33 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/models.py +7 -4
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/report.py +16 -4
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/secretspec.py +5 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/serve.py +6 -1
- {aiuse-3.3.2 → aiuse-3.3.4/src/aiuse.egg-info}/PKG-INFO +1 -2
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/SOURCES.txt +5 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_bailian.py +72 -1
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_claude_sublimits.py +15 -1
- aiuse-3.3.4/tests/test_client_limits.py +141 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_codexbar_parse.py +25 -0
- aiuse-3.3.4/tests/test_codexbar_timeouts.py +130 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_config.py +12 -0
- aiuse-3.3.4/tests/test_deepseek.py +128 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_models_cli_binary.py +0 -1
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_query_throttle.py +80 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_report.py +1 -2
- {aiuse-3.3.2 → aiuse-3.3.4}/LICENSE +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/setup.cfg +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/__main__.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/agent_notes.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/ai_stub.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/__init__.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/local_runtimes.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/pace.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/suggest.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/analysis/use_or_lose.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/attribute.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/chat_format.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/caut.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/clinepass.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/cswap.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/grok.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/hermes.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/muse.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/opencode_go.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/opencode_zen.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/openrouter.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/openusage.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/openusage_sh.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/qwencloud.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/collectors/tokscale.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/credentials.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/ledger.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/macos_trust.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/sampler.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/tty.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/tui/__init__.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/tui/app.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/tui/builders.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse/watch.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/dependency_links.txt +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/entry_points.txt +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/requires.txt +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/src/aiuse.egg-info/top_level.txt +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_attribution.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_base_run_json.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_caut_parse.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_chat_format.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_cli.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_clinepass.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_credentials.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_cswap_parse.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_grok.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_hermes_parse.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_history.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_install_deps.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_local_runtimes.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_macos_trust.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_models_pace.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_muse.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_opencode_go.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_opencode_zen.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_openrouter.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_openusage_cli_resolution.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_openusage_parse.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_pace.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_qwencloud.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_release_script.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_runner_consolidation.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_selfdescribe.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_serve.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_shared_quota_semantics.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_snapshots_persistence.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_suggest.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_tokscale_parse.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_tty.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_tui.py +0 -0
- {aiuse-3.3.2 → aiuse-3.3.4}/tests/test_use_or_lose.py +0 -0
- {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.
|
|
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`
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
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,
|
|
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
|
|
234
|
-
return
|
|
235
|
-
|
|
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
|
-
|
|
276
|
-
if
|
|
277
|
-
|
|
278
|
-
if _usable_usage_payload(
|
|
279
|
-
return
|
|
280
|
-
#
|
|
281
|
-
#
|
|
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
|
|