aiuse 3.2.0__tar.gz → 3.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aiuse-3.2.0/src/aiuse.egg-info → aiuse-3.2.2}/PKG-INFO +1 -1
- {aiuse-3.2.0 → aiuse-3.2.2}/pyproject.toml +1 -1
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/__init__.py +1 -1
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/selfdescribe.py +5 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/report.py +10 -2
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/sampler.py +10 -1
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/watch.py +139 -23
- {aiuse-3.2.0 → aiuse-3.2.2/src/aiuse.egg-info}/PKG-INFO +1 -1
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_selfdescribe.py +10 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_watch.py +112 -1
- {aiuse-3.2.0 → aiuse-3.2.2}/LICENSE +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/README.md +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/setup.cfg +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/__main__.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/agent_notes.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/ai_stub.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/__init__.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/history.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/local_runtimes.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/pace.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/suggest.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/analysis/use_or_lose.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/attribute.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/chat_format.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/cli.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/__init__.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/bailian.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/base.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/caut.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/clinepass.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/codexbar.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/cswap.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/grok.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/hermes.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/muse.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/opencode_go.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/opencode_zen.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/openrouter.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/openusage.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/openusage_sh.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/qwencloud.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/runner.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/collectors/tokscale.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/config.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/credentials.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/ledger.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/macos_trust.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/models.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/secretspec.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/serve.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/tty.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/tui/__init__.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/tui/app.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse/tui/builders.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/SOURCES.txt +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/dependency_links.txt +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/entry_points.txt +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/requires.txt +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/src/aiuse.egg-info/top_level.txt +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_attribution.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_bailian.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_base_run_json.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_caut_parse.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_chat_format.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_cli.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_clinepass.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_codexbar_parse.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_config.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_credentials.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_cswap_parse.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_grok.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_hermes_parse.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_history.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_install_deps.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_local_runtimes.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_macos_trust.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_models_cli_binary.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_models_pace.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_muse.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_opencode_go.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_opencode_zen.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_openrouter.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_openusage_parse.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_pace.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_qwencloud.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_release_script.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_report.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_runner_consolidation.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_serve.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_shared_quota_semantics.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_snapshots_persistence.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_suggest.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_tokscale_parse.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_tty.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_tui.py +0 -0
- {aiuse-3.2.0 → aiuse-3.2.2}/tests/test_use_or_lose.py +0 -0
|
@@ -317,6 +317,11 @@ def summary_line(pool: Mapping[str, Any]) -> str:
|
|
|
317
317
|
provider = str(pool.get("provider") or "?")
|
|
318
318
|
family = pool.get("pool_family")
|
|
319
319
|
lead = f"{provider} {family}" if family else provider
|
|
320
|
+
# The command you actually type, when it is not the provider id: `cursor`
|
|
321
|
+
# opens the editor (the agent is cursor-agent), antigravity is `agy`.
|
|
322
|
+
binary = pool.get("cli_binary")
|
|
323
|
+
if binary and str(binary) != provider:
|
|
324
|
+
lead += f" [{binary}]"
|
|
320
325
|
segments: list[str] = []
|
|
321
326
|
for window in pool.get("windows") or []:
|
|
322
327
|
label = str(window.get("label") or "?")
|
|
@@ -280,7 +280,7 @@ def render_report(
|
|
|
280
280
|
lines.append(s.bold("=" * width))
|
|
281
281
|
title = "AI USAGE — USE IT OR LOSE IT (full)"
|
|
282
282
|
lines.append(s.bold(s.cyan(title)))
|
|
283
|
-
meta = f"Collected at {snapshot.collected_at
|
|
283
|
+
meta = f"Collected at {format_clock(snapshot.collected_at, date=True)}"
|
|
284
284
|
meta += f" · {n_accounts} account{'s' if n_accounts != 1 else ''}"
|
|
285
285
|
if n_actionable:
|
|
286
286
|
meta += f" · {n_actionable} alert{'s' if n_actionable != 1 else ''}"
|
|
@@ -1703,6 +1703,14 @@ def _priority_account_line(
|
|
|
1703
1703
|
return f"{_priority_tag(s, band)} {body}"
|
|
1704
1704
|
|
|
1705
1705
|
|
|
1706
|
+
def format_clock(when: datetime, *, seconds: bool = False, date: bool = False) -> str:
|
|
1707
|
+
"""Local 12-hour time as people say it: ``9:02pm``, ``9:02:07pm``, ``Sat Oct 5 9:02pm``."""
|
|
1708
|
+
local = when.astimezone()
|
|
1709
|
+
clock = f"{local.hour % 12 or 12}:{local:%M}" + (f":{local:%S}" if seconds else "")
|
|
1710
|
+
clock += "am" if local.hour < 12 else "pm"
|
|
1711
|
+
return f"{local:%a %b} {local.day} {clock}" if date else clock
|
|
1712
|
+
|
|
1713
|
+
|
|
1706
1714
|
def render_stderr_meta(
|
|
1707
1715
|
snapshot: Snapshot,
|
|
1708
1716
|
alerts: list[UseOrLoseAlert],
|
|
@@ -1715,7 +1723,7 @@ def render_stderr_meta(
|
|
|
1715
1723
|
n_accounts = len(accounts)
|
|
1716
1724
|
n_actionable = sum(1 for a in alerts if a.urgency not in (Urgency.INFO, Urgency.NONE))
|
|
1717
1725
|
lines: list[str] = []
|
|
1718
|
-
meta = f"Collected at {snapshot.collected_at
|
|
1726
|
+
meta = f"Collected at {format_clock(snapshot.collected_at, date=True)}"
|
|
1719
1727
|
meta += f" · {n_accounts} account{'s' if n_accounts != 1 else ''}"
|
|
1720
1728
|
if n_actionable:
|
|
1721
1729
|
meta += f" · {n_actionable} alert{'s' if n_actionable != 1 else ''}"
|
|
@@ -24,7 +24,7 @@ import os
|
|
|
24
24
|
import sys
|
|
25
25
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
26
26
|
from dataclasses import dataclass, field
|
|
27
|
-
from datetime import datetime, timezone
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
from typing import Any, Callable
|
|
30
30
|
|
|
@@ -109,6 +109,15 @@ def decide(state: dict[str, Any], settings: dict[str, float], now: datetime, *,
|
|
|
109
109
|
return Decision("full", f"{tier} tier")
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def schedule(state: dict[str, Any], settings: dict[str, float]) -> tuple[datetime, datetime, str] | None:
|
|
113
|
+
"""``(previous sample, next sample due, tier)``, or None before the first sample."""
|
|
114
|
+
last = _parse(state.get("last_sample_at"))
|
|
115
|
+
if last is None:
|
|
116
|
+
return None
|
|
117
|
+
tier = state.get("tier") if state.get("tier") in TIERS else "idle"
|
|
118
|
+
return last, last + timedelta(seconds=settings[f"{tier}_interval"]), str(tier)
|
|
119
|
+
|
|
120
|
+
|
|
112
121
|
def _series(snapshot: Snapshot) -> dict[str, dict[str, Any]]:
|
|
113
122
|
out: dict[str, dict[str, Any]] = {}
|
|
114
123
|
for account in snapshot.accounts:
|
|
@@ -20,7 +20,7 @@ from aiuse.analysis.local_runtimes import maybe_local_runtime_alerts
|
|
|
20
20
|
from aiuse.analysis.use_or_lose import analyze_use_or_lose
|
|
21
21
|
from aiuse.collectors.runner import run_collectors
|
|
22
22
|
from aiuse.models import Snapshot, UseOrLoseAlert, utcnow
|
|
23
|
-
from aiuse.report import render_clock_matrix, render_stderr_meta
|
|
23
|
+
from aiuse.report import _strip_ansi, format_clock, render_clock_matrix, render_stderr_meta
|
|
24
24
|
|
|
25
25
|
DEFAULT_INTERVAL_S = 600.0
|
|
26
26
|
MIN_INTERVAL_S = 5.0
|
|
@@ -57,25 +57,115 @@ def parse_interval(value: str | float | int) -> float:
|
|
|
57
57
|
return seconds
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
def
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
def _overlay_burst_samples(snapshot: Snapshot) -> bool:
|
|
61
|
+
"""Replace accounts with any newer burst-tier reading. True if one applied.
|
|
62
|
+
|
|
63
|
+
During a burst the sampler reads the burning providers every few minutes
|
|
64
|
+
into partial samples, while full snapshots stay 15 minutes apart. The
|
|
65
|
+
board is most useful exactly then, so it shows the newer reading for those
|
|
66
|
+
providers and the full snapshot for everything else.
|
|
67
|
+
"""
|
|
68
|
+
from aiuse.analysis.history import account_key, partial_sample_dir
|
|
69
|
+
from aiuse.ledger import load_json_range
|
|
70
|
+
from aiuse.models import canonical_provider
|
|
71
|
+
from aiuse.serve import _snapshot_from_accounts_dict
|
|
72
|
+
|
|
73
|
+
applied = False
|
|
74
|
+
for row in load_json_range(partial_sample_dir(), snapshot.collected_at, None):
|
|
75
|
+
for fresh in _snapshot_from_accounts_dict(row).accounts:
|
|
76
|
+
if fresh.error or not fresh.windows:
|
|
77
|
+
continue
|
|
78
|
+
provider = canonical_provider(fresh.provider)
|
|
79
|
+
same = [i for i, a in enumerate(snapshot.accounts) if canonical_provider(a.provider) == provider]
|
|
80
|
+
exact = [i for i in same if account_key(snapshot.accounts[i].account) == account_key(fresh.account)]
|
|
81
|
+
# A provider-scoped reading belongs to the provider's only account.
|
|
82
|
+
target = exact[0] if exact else same[0] if len(same) == 1 else None
|
|
83
|
+
if target is None:
|
|
84
|
+
continue
|
|
85
|
+
fresh.account = fresh.account or snapshot.accounts[target].account
|
|
86
|
+
snapshot.accounts[target] = fresh
|
|
87
|
+
applied = True
|
|
88
|
+
return applied
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _frame_from_disk(max_age: float, config: dict[str, Any]) -> tuple[Snapshot, list[UseOrLoseAlert]] | None:
|
|
92
|
+
"""The newest full snapshot on disk, if it is younger than ``max_age`` seconds."""
|
|
93
|
+
from aiuse.analysis.history import load_recent_snapshots
|
|
94
|
+
from aiuse.serve import _alerts_from_dicts, _disk_row_age_ok, _snapshot_from_accounts_dict
|
|
95
|
+
|
|
96
|
+
rows = load_recent_snapshots(max_count=1)
|
|
97
|
+
if not rows or not _disk_row_age_ok(rows[0], max_age):
|
|
98
|
+
return None
|
|
99
|
+
snapshot = _snapshot_from_accounts_dict(rows[0])
|
|
100
|
+
if _overlay_burst_samples(snapshot):
|
|
101
|
+
# The stored alerts describe the older readings; re-derive them.
|
|
102
|
+
alerts = analyze_use_or_lose(snapshot, config)
|
|
103
|
+
alerts.extend(maybe_local_runtime_alerts(snapshot, config=config))
|
|
104
|
+
return snapshot, alerts
|
|
105
|
+
return snapshot, _alerts_from_dicts(rows[0].get("alerts") or [])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def collect_watch_frame(
|
|
109
|
+
config: dict[str, Any], *, max_age: float | None = None
|
|
110
|
+
) -> tuple[Snapshot, list[UseOrLoseAlert]]:
|
|
111
|
+
"""One frame for the board, sharing one polling pipeline with ``aiuse sample``.
|
|
112
|
+
|
|
113
|
+
With ``max_age`` set, a snapshot the scheduled sampler (or anything else)
|
|
114
|
+
wrote within that many seconds is shown as is: the board and the sampler
|
|
115
|
+
are not two clocks polling the same vendors. Otherwise this collects, and
|
|
116
|
+
records the result the way a sample does — snapshot, token ledger, sampler
|
|
117
|
+
state — so the scheduled job sees a fresh sample and skips its own.
|
|
118
|
+
"""
|
|
65
119
|
raw_analysis = config.get("analysis")
|
|
66
120
|
analysis_cfg: dict[str, Any] = raw_analysis if isinstance(raw_analysis, dict) else {}
|
|
67
|
-
|
|
121
|
+
persist = should_persist_snapshots(analysis_cfg)
|
|
122
|
+
if max_age and persist:
|
|
123
|
+
cached = _frame_from_disk(max_age, config)
|
|
124
|
+
if cached is not None:
|
|
125
|
+
return cached
|
|
126
|
+
|
|
127
|
+
from aiuse import sampler
|
|
128
|
+
from aiuse.ledger import save_ledger
|
|
129
|
+
|
|
130
|
+
ledger_sample = None
|
|
131
|
+
if persist:
|
|
132
|
+
snapshot, ledger_sample, _ledger_error = sampler.collect_with_ledger(config, run_collectors)
|
|
133
|
+
else:
|
|
134
|
+
snapshot = run_collectors(config)
|
|
135
|
+
alerts = analyze_use_or_lose(snapshot, config)
|
|
136
|
+
alerts.extend(maybe_local_runtime_alerts(snapshot, config=config))
|
|
137
|
+
if persist:
|
|
138
|
+
retention = int(analysis_cfg.get("snapshot_retention_days") or 90)
|
|
68
139
|
try:
|
|
69
|
-
save_snapshot(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
140
|
+
save_snapshot(snapshot, alerts, retention_days=retention)
|
|
141
|
+
if ledger_sample is not None:
|
|
142
|
+
save_ledger(ledger_sample, collected_at=snapshot.collected_at, retention_days=retention)
|
|
143
|
+
settings = sampler.sampling_settings(config)
|
|
144
|
+
sampler.save_state(sampler.advance_state(sampler.load_state(), snapshot, settings, partial=False))
|
|
74
145
|
except OSError:
|
|
75
146
|
pass
|
|
76
147
|
return snapshot, alerts
|
|
77
148
|
|
|
78
149
|
|
|
150
|
+
_schedule_cache: tuple[float, tuple[datetime, datetime, str] | None] | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _sample_schedule(config: dict[str, Any]) -> tuple[datetime, datetime, str] | None:
|
|
154
|
+
"""When ``aiuse sample`` last ran and is next due. Re-read every 2 seconds:
|
|
155
|
+
the board redraws four times a second and the state file changes rarely."""
|
|
156
|
+
global _schedule_cache
|
|
157
|
+
from aiuse import sampler
|
|
158
|
+
|
|
159
|
+
if _schedule_cache is not None and time.monotonic() - _schedule_cache[0] < 2.0:
|
|
160
|
+
return _schedule_cache[1]
|
|
161
|
+
try:
|
|
162
|
+
value = sampler.schedule(sampler.load_state(), sampler.sampling_settings(config))
|
|
163
|
+
except Exception: # noqa: BLE001 — a header detail must never take the board down
|
|
164
|
+
value = None
|
|
165
|
+
_schedule_cache = (time.monotonic(), value)
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
|
|
79
169
|
def render_watch_board(
|
|
80
170
|
snapshot: Snapshot | None,
|
|
81
171
|
alerts: list[UseOrLoseAlert],
|
|
@@ -87,11 +177,21 @@ def render_watch_board(
|
|
|
87
177
|
next_in: float | None = None,
|
|
88
178
|
collecting_for: float | None = None,
|
|
89
179
|
error: str | None = None,
|
|
180
|
+
now: datetime | None = None,
|
|
181
|
+
sample_schedule: tuple[datetime, datetime, str] | None = None,
|
|
90
182
|
) -> str:
|
|
91
|
-
"""Header + clock matrix + optional footer for the alternate-screen board.
|
|
183
|
+
"""Header + clock matrix + optional footer for the alternate-screen board.
|
|
184
|
+
|
|
185
|
+
``last`` is when the data on the board was collected, which is not when
|
|
186
|
+
the board last refreshed: a frame can come from a snapshot the scheduled
|
|
187
|
+
sampler wrote some minutes ago.
|
|
188
|
+
"""
|
|
92
189
|
header_bits = ["aiuse watch"]
|
|
93
|
-
if
|
|
94
|
-
header_bits.append(f"
|
|
190
|
+
if now is not None:
|
|
191
|
+
header_bits.append(f"now: {format_clock(now, seconds=True)}")
|
|
192
|
+
data_at = snapshot.collected_at if snapshot is not None else last_at
|
|
193
|
+
if data_at is not None:
|
|
194
|
+
header_bits.append(f"last: {format_clock(data_at, seconds=True)}")
|
|
95
195
|
else:
|
|
96
196
|
header_bits.append("last: —")
|
|
97
197
|
if collecting_for is not None:
|
|
@@ -101,14 +201,25 @@ def render_watch_board(
|
|
|
101
201
|
header_bits.append(f"next in {mins}:{secs:02d}")
|
|
102
202
|
header_bits.append("q/esc quit")
|
|
103
203
|
lines = [" · ".join(header_bits)]
|
|
204
|
+
if sample_schedule is not None:
|
|
205
|
+
previous, due, tier = sample_schedule
|
|
206
|
+
overdue = " (due)" if now is not None and due <= now else ""
|
|
207
|
+
lines.append(
|
|
208
|
+
f"sampler: previous {format_clock(previous, seconds=True)}"
|
|
209
|
+
f" · next {format_clock(due, seconds=True)}{overdue} · {tier} tier"
|
|
210
|
+
)
|
|
104
211
|
if error:
|
|
105
212
|
lines.append(f"collect error: {error}")
|
|
106
213
|
if snapshot is not None:
|
|
107
|
-
|
|
214
|
+
matrix = render_clock_matrix(alerts, snapshot=snapshot, config=config, color=color).rstrip()
|
|
215
|
+
lines.append(matrix)
|
|
108
216
|
if not quiet:
|
|
109
217
|
footer = render_stderr_meta(snapshot, alerts, color=color).rstrip()
|
|
110
218
|
if footer:
|
|
111
|
-
lines.
|
|
219
|
+
# Centered under the table, like the legend lines above it.
|
|
220
|
+
width = max((len(_strip_ansi(row)) for row in matrix.splitlines()), default=0)
|
|
221
|
+
for row in footer.splitlines():
|
|
222
|
+
lines.append(" " * max(0, (width - len(_strip_ansi(row))) // 2) + row)
|
|
112
223
|
else:
|
|
113
224
|
lines.append("waiting for first collection…")
|
|
114
225
|
return "\n".join(lines)
|
|
@@ -223,7 +334,7 @@ class WatchRuntime:
|
|
|
223
334
|
return max(0.0, self.next_due - self.now())
|
|
224
335
|
|
|
225
336
|
|
|
226
|
-
def _collect_process_entry(config: dict[str, Any], send: Any) -> None:
|
|
337
|
+
def _collect_process_entry(config: dict[str, Any], send: Any, max_age: float | None = None) -> None:
|
|
227
338
|
"""Collect in an isolated process so an in-flight refresh is cancellable."""
|
|
228
339
|
if os.name == "posix":
|
|
229
340
|
try:
|
|
@@ -231,7 +342,7 @@ def _collect_process_entry(config: dict[str, Any], send: Any) -> None:
|
|
|
231
342
|
except OSError:
|
|
232
343
|
pass
|
|
233
344
|
try:
|
|
234
|
-
snapshot, alerts = collect_watch_frame(config)
|
|
345
|
+
snapshot, alerts = collect_watch_frame(config, max_age=max_age)
|
|
235
346
|
send.send(("ok", snapshot, alerts))
|
|
236
347
|
except BaseException as exc: # noqa: BLE001 — return a board error instead of losing the worker
|
|
237
348
|
try:
|
|
@@ -245,8 +356,9 @@ def _collect_process_entry(config: dict[str, Any], send: Any) -> None:
|
|
|
245
356
|
class _WatchCollectionProcess:
|
|
246
357
|
"""One cancellable live-collection process for interactive watch mode."""
|
|
247
358
|
|
|
248
|
-
def __init__(self, config: dict[str, Any]) -> None:
|
|
359
|
+
def __init__(self, config: dict[str, Any], max_age: float | None = None) -> None:
|
|
249
360
|
self.config = config
|
|
361
|
+
self.max_age = max_age
|
|
250
362
|
methods = multiprocessing.get_all_start_methods()
|
|
251
363
|
self.context: Any = multiprocessing.get_context("fork" if "fork" in methods else "spawn")
|
|
252
364
|
self.process: multiprocessing.Process | None = None
|
|
@@ -256,7 +368,7 @@ class _WatchCollectionProcess:
|
|
|
256
368
|
recv, send = self.context.Pipe(duplex=False)
|
|
257
369
|
process = self.context.Process(
|
|
258
370
|
target=_collect_process_entry,
|
|
259
|
-
args=(self.config, send),
|
|
371
|
+
args=(self.config, send, self.max_age),
|
|
260
372
|
name="aiuse-watch-collect",
|
|
261
373
|
daemon=True,
|
|
262
374
|
)
|
|
@@ -337,7 +449,7 @@ def run_watch(
|
|
|
337
449
|
out = stdout if stdout is not None else sys.stdout
|
|
338
450
|
err = stderr if stderr is not None else sys.stderr
|
|
339
451
|
color = False if no_color else None
|
|
340
|
-
collect_fn = collect or (lambda: collect_watch_frame(config))
|
|
452
|
+
collect_fn = collect or (lambda: collect_watch_frame(config, max_age=interval))
|
|
341
453
|
runtime = WatchRuntime(interval=interval, collect=collect_fn, now=now or time.monotonic)
|
|
342
454
|
|
|
343
455
|
if once:
|
|
@@ -353,6 +465,8 @@ def run_watch(
|
|
|
353
465
|
next_in=None,
|
|
354
466
|
collecting_for=None,
|
|
355
467
|
error=runtime.error,
|
|
468
|
+
now=utcnow(),
|
|
469
|
+
sample_schedule=_sample_schedule(config),
|
|
356
470
|
),
|
|
357
471
|
file=out,
|
|
358
472
|
)
|
|
@@ -384,7 +498,7 @@ def run_watch(
|
|
|
384
498
|
)
|
|
385
499
|
reader = key_reader or StdinKeyReader()
|
|
386
500
|
stop = threading.Event()
|
|
387
|
-
process_worker = _WatchCollectionProcess(config) if collect is None else None
|
|
501
|
+
process_worker = _WatchCollectionProcess(config, max_age=interval) if collect is None else None
|
|
388
502
|
|
|
389
503
|
def start_worker(fn: Callable[[], None]) -> None:
|
|
390
504
|
if process_worker is not None:
|
|
@@ -423,6 +537,8 @@ def run_watch(
|
|
|
423
537
|
next_in=runtime.next_in(),
|
|
424
538
|
collecting_for=runtime.collecting_for(),
|
|
425
539
|
error=runtime.error,
|
|
540
|
+
now=utcnow(),
|
|
541
|
+
sample_schedule=_sample_schedule(config),
|
|
426
542
|
)
|
|
427
543
|
)
|
|
428
544
|
|
|
@@ -435,3 +435,13 @@ def test_plain_output_never_bare_percentage():
|
|
|
435
435
|
for match in bare.finditer(line):
|
|
436
436
|
tail = line[match.start() :]
|
|
437
437
|
assert labeled.search(tail), f"bare percentage {match.group()!r} in line: {line!r}"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_summary_line_names_the_binary_when_it_differs_from_the_provider_id():
|
|
441
|
+
from aiuse.analysis.selfdescribe import summary_line
|
|
442
|
+
|
|
443
|
+
window = {"label": "Cursor Auto", "state": "ok", "used_percent": 1.0, "headroom_percent": 99.0}
|
|
444
|
+
cursor = {"provider": "cursor", "pool_family": "auto", "cli_binary": "cursor-agent", "windows": [window]}
|
|
445
|
+
assert summary_line(cursor).startswith("cursor auto [cursor-agent]: Cursor Auto: ok")
|
|
446
|
+
codex = {"provider": "codex", "pool_family": None, "cli_binary": "codex", "windows": [window]}
|
|
447
|
+
assert summary_line(codex).startswith("codex: ")
|
|
@@ -205,7 +205,7 @@ def test_run_watch_stops_default_collection_process_on_quit(monkeypatch):
|
|
|
205
205
|
instances = []
|
|
206
206
|
|
|
207
207
|
class FakeProcessWorker:
|
|
208
|
-
def __init__(self, config):
|
|
208
|
+
def __init__(self, config, max_age=None):
|
|
209
209
|
self.config = config
|
|
210
210
|
self.started = False
|
|
211
211
|
self.stop_calls = 0
|
|
@@ -307,3 +307,114 @@ def test_collect_watch_frame_persists_when_enabled(monkeypatch):
|
|
|
307
307
|
monkeypatch.setattr("aiuse.watch.save_snapshot", lambda *a, **k: saved.append(True) or "/tmp/x")
|
|
308
308
|
collect_watch_frame({"analysis": {"persist_snapshots": True}})
|
|
309
309
|
assert saved == [True]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_watch_frame_reuses_a_fresh_snapshot_instead_of_collecting(monkeypatch):
|
|
313
|
+
"""The board rides the scheduled sampler's data rather than polling beside it."""
|
|
314
|
+
from aiuse.analysis.history import save_snapshot
|
|
315
|
+
|
|
316
|
+
save_snapshot(_snap(), [])
|
|
317
|
+
monkeypatch.setattr("aiuse.watch.run_collectors", lambda _c: pytest.fail("must not collect"))
|
|
318
|
+
snapshot, alerts = collect_watch_frame({"analysis": {"persist_snapshots": True}}, max_age=600)
|
|
319
|
+
assert [a.provider for a in snapshot.accounts] == [a.provider for a in _snap().accounts]
|
|
320
|
+
assert alerts == []
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_watch_frame_collects_when_the_snapshot_is_stale_and_records_a_sample(monkeypatch):
|
|
324
|
+
from datetime import timedelta
|
|
325
|
+
|
|
326
|
+
from aiuse import sampler
|
|
327
|
+
from aiuse.analysis.history import load_recent_snapshots, save_snapshot
|
|
328
|
+
|
|
329
|
+
old = _snap()
|
|
330
|
+
old.collected_at -= timedelta(hours=2)
|
|
331
|
+
save_snapshot(old, [])
|
|
332
|
+
calls: list[int] = []
|
|
333
|
+
monkeypatch.setattr("aiuse.watch.run_collectors", lambda _c: calls.append(1) or _snap())
|
|
334
|
+
monkeypatch.setattr("aiuse.watch.analyze_use_or_lose", lambda *_a, **_k: [])
|
|
335
|
+
monkeypatch.setattr("aiuse.watch.maybe_local_runtime_alerts", lambda *_a, **_k: [])
|
|
336
|
+
collect_watch_frame({"analysis": {"persist_snapshots": True}}, max_age=600)
|
|
337
|
+
assert calls == [1] and len(load_recent_snapshots(retention_days=10_000)) == 2
|
|
338
|
+
# The scheduled job now sees a sample this recent and skips its own.
|
|
339
|
+
state = sampler.load_state()
|
|
340
|
+
assert sampler.decide(state, sampler.sampling_settings(None), _snap().collected_at).action == "skip"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_board_header_shows_now_data_time_and_the_sampler_schedule():
|
|
344
|
+
from datetime import datetime, timedelta, timezone
|
|
345
|
+
|
|
346
|
+
from aiuse import sampler
|
|
347
|
+
from aiuse.report import format_clock
|
|
348
|
+
from aiuse.watch import render_watch_board
|
|
349
|
+
|
|
350
|
+
snap = _snap()
|
|
351
|
+
snap.collected_at = datetime(2026, 10, 3, 12, 0, 5, tzinfo=timezone.utc)
|
|
352
|
+
now = snap.collected_at + timedelta(minutes=7)
|
|
353
|
+
state = {"tier": "active", "last_sample_at": snap.collected_at.isoformat()}
|
|
354
|
+
schedule = sampler.schedule(state, sampler.sampling_settings(None))
|
|
355
|
+
assert schedule is not None and schedule[1] == snap.collected_at + timedelta(minutes=15)
|
|
356
|
+
board = render_watch_board(snap, [], color=False, quiet=True, now=now, sample_schedule=schedule)
|
|
357
|
+
header, sampler_line = board.splitlines()[:2]
|
|
358
|
+
|
|
359
|
+
def hms(value):
|
|
360
|
+
return format_clock(value, seconds=True)
|
|
361
|
+
|
|
362
|
+
assert f"now: {hms(now)}" in header and f"last: {hms(snap.collected_at)}" in header
|
|
363
|
+
assert sampler_line == f"sampler: previous {hms(snap.collected_at)} · next {hms(schedule[1])} · active tier"
|
|
364
|
+
late = render_watch_board(snap, [], color=False, quiet=True, now=now + timedelta(hours=1), sample_schedule=schedule)
|
|
365
|
+
assert "(due)" in late.splitlines()[1]
|
|
366
|
+
assert sampler.schedule({}, sampler.sampling_settings(None)) is None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_watch_frame_overlays_newer_burst_samples_on_the_full_snapshot(monkeypatch):
|
|
370
|
+
from datetime import timedelta
|
|
371
|
+
|
|
372
|
+
from aiuse.analysis.history import save_snapshot
|
|
373
|
+
|
|
374
|
+
def account(provider: str, used: float, source: str = "codexbar") -> AccountUsage:
|
|
375
|
+
window = QuotaWindow(label=f"{provider} weekly", used_percent=used, window_minutes=10080)
|
|
376
|
+
return AccountUsage(
|
|
377
|
+
source=source, provider=provider, billing_kind=BillingKind.SUBSCRIPTION_WINDOW, windows=[window]
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
full_at = utcnow() - timedelta(minutes=5)
|
|
381
|
+
save_snapshot(Snapshot(collected_at=full_at, accounts=[account("clinepass", 40), account("codex", 10)]), [])
|
|
382
|
+
for minutes, used in ((2, 44), (4, 47)):
|
|
383
|
+
burst = Snapshot(collected_at=full_at + timedelta(minutes=minutes), accounts=[account("clinepass", used)])
|
|
384
|
+
save_snapshot(burst, [], partial_providers=["clinepass"])
|
|
385
|
+
# A burst reading older than the full snapshot must not roll it back.
|
|
386
|
+
stale = Snapshot(collected_at=full_at - timedelta(minutes=3), accounts=[account("codex", 1)])
|
|
387
|
+
save_snapshot(stale, [], partial_providers=["codex"])
|
|
388
|
+
|
|
389
|
+
monkeypatch.setattr("aiuse.watch.run_collectors", lambda _c: pytest.fail("must not collect"))
|
|
390
|
+
seen: list[Snapshot] = []
|
|
391
|
+
monkeypatch.setattr("aiuse.watch.analyze_use_or_lose", lambda snap, _c: seen.append(snap) or [])
|
|
392
|
+
monkeypatch.setattr("aiuse.watch.maybe_local_runtime_alerts", lambda *_a, **_k: [])
|
|
393
|
+
snapshot, _alerts = collect_watch_frame({"analysis": {"persist_snapshots": True}}, max_age=600)
|
|
394
|
+
used = {a.provider: a.windows[0].used_percent for a in snapshot.accounts}
|
|
395
|
+
assert used == {"clinepass": 47, "codex": 10}
|
|
396
|
+
assert seen == [snapshot], "alerts are re-derived from the overlaid readings"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_format_clock_is_twelve_hour_without_leading_zeros():
|
|
400
|
+
from datetime import datetime
|
|
401
|
+
|
|
402
|
+
from aiuse.report import format_clock
|
|
403
|
+
|
|
404
|
+
evening = datetime(2026, 10, 3, 21, 2, 7).astimezone()
|
|
405
|
+
assert format_clock(evening) == "9:02pm"
|
|
406
|
+
assert format_clock(evening, seconds=True) == "9:02:07pm"
|
|
407
|
+
assert format_clock(evening, date=True) == "Sat Oct 3 9:02pm"
|
|
408
|
+
assert format_clock(datetime(2026, 10, 3, 0, 5).astimezone()) == "12:05am"
|
|
409
|
+
assert format_clock(datetime(2026, 10, 3, 12, 0).astimezone()) == "12:00pm"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_board_footer_is_centered_under_the_table():
|
|
413
|
+
from aiuse.watch import render_watch_board
|
|
414
|
+
|
|
415
|
+
rows = render_watch_board(_snap(), [], color=False).splitlines()
|
|
416
|
+
width = max(len(r) for r in rows[: rows.index(next(r for r in rows if "Collected at" in r))] if "watch" not in r)
|
|
417
|
+
meta = next(r for r in rows if "Collected at" in r)
|
|
418
|
+
left = len(meta) - len(meta.lstrip())
|
|
419
|
+
assert left == max(0, (width - len(meta.strip())) // 2)
|
|
420
|
+
assert "am ·" in meta or "pm ·" in meta
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|