aiuse 3.2.2__tar.gz → 3.2.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.2.2/src/aiuse.egg-info → aiuse-3.2.4}/PKG-INFO +5 -1
- {aiuse-3.2.2 → aiuse-3.2.4}/README.md +4 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/pyproject.toml +1 -1
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/__init__.py +1 -1
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/selfdescribe.py +4 -4
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/attribute.py +17 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/report.py +21 -5
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/sampler.py +41 -5
- {aiuse-3.2.2 → aiuse-3.2.4/src/aiuse.egg-info}/PKG-INFO +5 -1
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_attribution.py +69 -8
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_selfdescribe.py +13 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/LICENSE +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/setup.cfg +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/__main__.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/agent_notes.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/ai_stub.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/__init__.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/history.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/local_runtimes.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/pace.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/suggest.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/use_or_lose.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/chat_format.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/cli.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/__init__.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/bailian.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/base.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/caut.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/clinepass.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/codexbar.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/cswap.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/grok.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/hermes.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/muse.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/opencode_go.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/opencode_zen.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/openrouter.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/openusage.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/openusage_sh.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/qwencloud.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/runner.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/tokscale.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/config.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/credentials.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/ledger.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/macos_trust.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/models.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/secretspec.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/serve.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/tty.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/tui/__init__.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/tui/app.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/tui/builders.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/watch.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/SOURCES.txt +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/dependency_links.txt +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/entry_points.txt +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/requires.txt +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/top_level.txt +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_bailian.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_base_run_json.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_caut_parse.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_chat_format.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_cli.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_clinepass.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_codexbar_parse.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_config.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_credentials.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_cswap_parse.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_grok.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_hermes_parse.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_history.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_install_deps.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_local_runtimes.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_macos_trust.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_models_cli_binary.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_models_pace.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_muse.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_opencode_go.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_opencode_zen.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_openrouter.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_openusage_parse.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_pace.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_qwencloud.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_release_script.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_report.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_runner_consolidation.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_serve.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_shared_quota_semantics.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_snapshots_persistence.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_suggest.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_tokscale_parse.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_tty.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_tui.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_use_or_lose.py +0 -0
- {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiuse
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.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
|
|
@@ -321,6 +321,9 @@ aiuse serve # loopback HTTP API for agents
|
|
|
321
321
|
aiuse watch # full-screen board (q/esc quit; default 10m)
|
|
322
322
|
aiuse watch -i 2m # faster refresh
|
|
323
323
|
aiuse watch --once # one frame on stdout (scripts / tmux)
|
|
324
|
+
aiuse attribute # what spent the quota: points burned beside tokens per client
|
|
325
|
+
aiuse attribute --since 7d --provider clinepass --intervals
|
|
326
|
+
aiuse sample # scheduled entry point: samples faster while quota is moving
|
|
324
327
|
|
|
325
328
|
# Faster / partial
|
|
326
329
|
aiuse --providers copilot,grok,codex # query these separately
|
|
@@ -514,6 +517,7 @@ Lapsed subscriptions: `analysis.lapsed_accounts` maps `"provider/account"` to a
|
|
|
514
517
|
|
|
515
518
|
## Further documentation
|
|
516
519
|
|
|
520
|
+
- [`docs/attribution.md`](docs/attribution.md) — what spent the quota: token ledgers, adaptive sampling (`aiuse sample`), `aiuse attribute`.
|
|
517
521
|
- [`packaging/install-deps.sh`](packaging/install-deps.sh) — install the optional data-source tools.
|
|
518
522
|
- [`docs/json-contract.md`](docs/json-contract.md) — stable JSON fields and exit codes.
|
|
519
523
|
- [`docs/provider-identity.md`](docs/provider-identity.md) — canonical provider id vs config key, and window identity across collectors.
|
|
@@ -280,6 +280,9 @@ aiuse serve # loopback HTTP API for agents
|
|
|
280
280
|
aiuse watch # full-screen board (q/esc quit; default 10m)
|
|
281
281
|
aiuse watch -i 2m # faster refresh
|
|
282
282
|
aiuse watch --once # one frame on stdout (scripts / tmux)
|
|
283
|
+
aiuse attribute # what spent the quota: points burned beside tokens per client
|
|
284
|
+
aiuse attribute --since 7d --provider clinepass --intervals
|
|
285
|
+
aiuse sample # scheduled entry point: samples faster while quota is moving
|
|
283
286
|
|
|
284
287
|
# Faster / partial
|
|
285
288
|
aiuse --providers copilot,grok,codex # query these separately
|
|
@@ -473,6 +476,7 @@ Lapsed subscriptions: `analysis.lapsed_accounts` maps `"provider/account"` to a
|
|
|
473
476
|
|
|
474
477
|
## Further documentation
|
|
475
478
|
|
|
479
|
+
- [`docs/attribution.md`](docs/attribution.md) — what spent the quota: token ledgers, adaptive sampling (`aiuse sample`), `aiuse attribute`.
|
|
476
480
|
- [`packaging/install-deps.sh`](packaging/install-deps.sh) — install the optional data-source tools.
|
|
477
481
|
- [`docs/json-contract.md`](docs/json-contract.md) — stable JSON fields and exit codes.
|
|
478
482
|
- [`docs/provider-identity.md`](docs/provider-identity.md) — canonical provider id vs config key, and window identity across collectors.
|
|
@@ -306,10 +306,10 @@ def _reset_fragment(resets_at: Any) -> str:
|
|
|
306
306
|
dt = parse_dt(resets_at)
|
|
307
307
|
if dt is None:
|
|
308
308
|
return ""
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return f", resets {
|
|
309
|
+
# Local 12-hour time, the same form the watch board uses.
|
|
310
|
+
from aiuse.report import format_clock
|
|
311
|
+
|
|
312
|
+
return f", resets {format_clock(dt, date=dt - utcnow() > timedelta(hours=24))}"
|
|
313
313
|
|
|
314
314
|
|
|
315
315
|
def summary_line(pool: Mapping[str, Any]) -> str:
|
|
@@ -83,6 +83,21 @@ def _quota_series(samples: list[dict[str, Any]]) -> dict[tuple[str, str, str], d
|
|
|
83
83
|
provider = canonical_provider(str(account.get("provider") or ""))
|
|
84
84
|
accounts.setdefault(provider, set()).add(history.account_key(account["account"]))
|
|
85
85
|
|
|
86
|
+
# One source may omit a window's duration (CodexBar's Copilot row) that
|
|
87
|
+
# another reports; the label then says which window it is.
|
|
88
|
+
durations: dict[tuple[str, str], Any] = {}
|
|
89
|
+
for sample in samples:
|
|
90
|
+
for account in sample.get("accounts") or []:
|
|
91
|
+
if not isinstance(account, dict):
|
|
92
|
+
continue
|
|
93
|
+
provider = canonical_provider(str(account.get("provider") or ""))
|
|
94
|
+
for window in account.get("windows") or []:
|
|
95
|
+
if isinstance(window, dict):
|
|
96
|
+
label = str(window.get("label") or "")
|
|
97
|
+
minutes = effective_window_minutes(label, window.get("window_minutes"))
|
|
98
|
+
if minutes is not None:
|
|
99
|
+
durations.setdefault((provider, label), minutes)
|
|
100
|
+
|
|
86
101
|
series: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
87
102
|
for sample in samples:
|
|
88
103
|
when = _time(sample, "collected_at")
|
|
@@ -103,6 +118,8 @@ def _quota_series(samples: list[dict[str, Any]]) -> dict[tuple[str, str, str], d
|
|
|
103
118
|
continue
|
|
104
119
|
label = str(window.get("label") or "")
|
|
105
120
|
minutes = effective_window_minutes(label, window.get("window_minutes"))
|
|
121
|
+
if minutes is None:
|
|
122
|
+
minutes = durations.get((provider, label))
|
|
106
123
|
key = (provider, owner, history.window_series_key(provider, label, minutes))
|
|
107
124
|
if key in seen and (seen[key] or not named):
|
|
108
125
|
continue
|
|
@@ -1517,11 +1517,27 @@ def render_clock_matrix(
|
|
|
1517
1517
|
estimated=cell.reset_estimated,
|
|
1518
1518
|
compact=layout.compact_deadline,
|
|
1519
1519
|
)
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1520
|
+
# Split percent into used part (before "/") and left part (after "/" but before "l")
|
|
1521
|
+
# Format: "44u/56l" or "44u/56l+" if folded
|
|
1522
|
+
# Keep used part and slash in normal text, color only the left part
|
|
1523
|
+
if "/" in percent:
|
|
1524
|
+
used_part, rest = percent.split("/", 1)
|
|
1525
|
+
# rest is like "56l" or "56l+"
|
|
1526
|
+
left_part = rest.rstrip("l+")
|
|
1527
|
+
mark = "+" if "+" in rest else ""
|
|
1528
|
+
if cell.inferred:
|
|
1529
|
+
colored_used = s.dim(used_part)
|
|
1530
|
+
colored_left = s.dim(left_part)
|
|
1531
|
+
else:
|
|
1532
|
+
colored_used = used_part # Normal text for used part
|
|
1533
|
+
colored_left = _cell_color(s, cell.used_percent)(left_part)
|
|
1534
|
+
colored = f"{colored_used}/{colored_left}l{mark}"
|
|
1535
|
+
else:
|
|
1536
|
+
colored = (
|
|
1537
|
+
s.dim(f"{percent:>{w_pct}}")
|
|
1538
|
+
if cell.inferred
|
|
1539
|
+
else _cell_color(s, cell.used_percent)(f"{percent:>{w_pct}}")
|
|
1540
|
+
)
|
|
1525
1541
|
if span is None:
|
|
1526
1542
|
tail.append(colored + (" " * (w_clock - w_pct)))
|
|
1527
1543
|
continue
|
|
@@ -38,6 +38,13 @@ TIERS = ("idle", "active", "burst")
|
|
|
38
38
|
_SLACK_SECONDS = 15.0
|
|
39
39
|
# Two samples closer than this say nothing reliable about a rate.
|
|
40
40
|
_MIN_RATE_SECONDS = 60.0
|
|
41
|
+
# A burst is judged against a reading at least this fraction of
|
|
42
|
+
# ``active_interval`` old. Meters report whole points, so one tick between two
|
|
43
|
+
# samples four minutes apart reads as 15 points an hour; over nine minutes or
|
|
44
|
+
# more a single tick stays under the threshold and a real sprint does not.
|
|
45
|
+
_BURST_BASELINE_FRACTION = 0.6
|
|
46
|
+
# Readings kept per window for that baseline.
|
|
47
|
+
_HISTORY_SECONDS = 7200.0
|
|
41
48
|
|
|
42
49
|
|
|
43
50
|
def state_path() -> Path:
|
|
@@ -146,26 +153,54 @@ def advance_state(
|
|
|
146
153
|
``burst_pace_ratio`` times the pace that would exactly exhaust it — the
|
|
147
154
|
second test keeps a 5-hour window's ordinary 20 points/hour from reading
|
|
148
155
|
as an emergency while still catching a weekly window at 8.
|
|
156
|
+
The burst rate is measured from a reading at least 0.6 × ``active_interval``
|
|
157
|
+
old, never from the sample just before: see ``_BURST_BASELINE_FRACTION``.
|
|
149
158
|
Rising is fast (one sample); falling takes ``cooldown_samples`` quiet ones.
|
|
150
159
|
"""
|
|
151
160
|
raw_previous = state.get("last_values")
|
|
152
161
|
previous: dict[str, Any] = raw_previous if isinstance(raw_previous, dict) else {}
|
|
153
162
|
current = _series(snapshot)
|
|
163
|
+
raw_trail = state.get("history")
|
|
164
|
+
trail: dict[str, list[list[Any]]] = (
|
|
165
|
+
{k: list(v) for k, v in raw_trail.items() if isinstance(v, list)} if isinstance(raw_trail, dict) else {}
|
|
166
|
+
)
|
|
167
|
+
baseline_age = _BURST_BASELINE_FRACTION * settings["active_interval"]
|
|
154
168
|
moved: list[str] = []
|
|
155
169
|
hot: dict[str, dict[str, str]] = {}
|
|
156
170
|
for key, cur in current.items():
|
|
171
|
+
readings = trail.setdefault(key, [])
|
|
157
172
|
prev = previous.get(key)
|
|
158
173
|
prev_at = _parse(prev.get("at")) if isinstance(prev, dict) else None
|
|
174
|
+
if isinstance(prev, dict) and cur["used"] < float(prev.get("used") or 0.0):
|
|
175
|
+
readings.clear() # the window reset: older readings are another cycle
|
|
176
|
+
# Newest reading old enough to judge a rate against.
|
|
177
|
+
old_enough = [
|
|
178
|
+
(at, float(used))
|
|
179
|
+
for at, used in ((_parse(r[0]), r[1]) for r in readings if isinstance(r, list) and len(r) == 2)
|
|
180
|
+
if at is not None and (snapshot.collected_at - at).total_seconds() >= baseline_age
|
|
181
|
+
]
|
|
182
|
+
readings.append([snapshot.collected_at.isoformat(), cur["used"]])
|
|
183
|
+
readings[:] = [
|
|
184
|
+
r
|
|
185
|
+
for r in readings
|
|
186
|
+
if (at := _parse(r[0])) is not None and (snapshot.collected_at - at).total_seconds() <= _HISTORY_SECONDS
|
|
187
|
+
]
|
|
159
188
|
if prev_at is None or not isinstance(prev, dict):
|
|
160
189
|
continue
|
|
161
190
|
seconds = (snapshot.collected_at - prev_at).total_seconds()
|
|
162
191
|
delta = cur["used"] - float(prev.get("used") or 0.0)
|
|
163
|
-
if seconds
|
|
164
|
-
|
|
165
|
-
|
|
192
|
+
if seconds >= _MIN_RATE_SECONDS and delta >= settings["min_move_percent"]:
|
|
193
|
+
moved.append(f"{cur['provider']} {cur['label']} +{delta:.1f} in {seconds / 60.0:.0f}m")
|
|
194
|
+
if not old_enough:
|
|
195
|
+
continue
|
|
196
|
+
base_at, base_used = old_enough[-1]
|
|
197
|
+
span = (snapshot.collected_at - base_at).total_seconds()
|
|
198
|
+
rise = cur["used"] - base_used
|
|
199
|
+
if rise < settings["min_move_percent"]:
|
|
200
|
+
continue
|
|
201
|
+
per_hour = rise / (span / 3600.0)
|
|
166
202
|
minutes = cur.get("window_minutes")
|
|
167
|
-
pace = (
|
|
168
|
-
moved.append(f"{cur['provider']} {cur['label']} +{delta:.1f} ({per_hour:.1f}/h)")
|
|
203
|
+
pace = (rise / 100.0) / ((span / 60.0) / float(minutes)) if minutes else float("inf")
|
|
169
204
|
if per_hour >= settings["burst_percent_per_hour"] and pace >= settings["burst_pace_ratio"]:
|
|
170
205
|
hot[cur["provider"]] = {"provider": cur["provider"], "source": cur["source"]}
|
|
171
206
|
|
|
@@ -191,6 +226,7 @@ def advance_state(
|
|
|
191
226
|
"moved": moved,
|
|
192
227
|
# A partial sample only refreshes the windows it collected.
|
|
193
228
|
"last_values": {**previous, **current} if partial else current,
|
|
229
|
+
"history": trail if partial else {k: v for k, v in trail.items() if k in current},
|
|
194
230
|
}
|
|
195
231
|
)
|
|
196
232
|
if not partial:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiuse
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.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
|
|
@@ -321,6 +321,9 @@ aiuse serve # loopback HTTP API for agents
|
|
|
321
321
|
aiuse watch # full-screen board (q/esc quit; default 10m)
|
|
322
322
|
aiuse watch -i 2m # faster refresh
|
|
323
323
|
aiuse watch --once # one frame on stdout (scripts / tmux)
|
|
324
|
+
aiuse attribute # what spent the quota: points burned beside tokens per client
|
|
325
|
+
aiuse attribute --since 7d --provider clinepass --intervals
|
|
326
|
+
aiuse sample # scheduled entry point: samples faster while quota is moving
|
|
324
327
|
|
|
325
328
|
# Faster / partial
|
|
326
329
|
aiuse --providers copilot,grok,codex # query these separately
|
|
@@ -514,6 +517,7 @@ Lapsed subscriptions: `analysis.lapsed_accounts` maps `"provider/account"` to a
|
|
|
514
517
|
|
|
515
518
|
## Further documentation
|
|
516
519
|
|
|
520
|
+
- [`docs/attribution.md`](docs/attribution.md) — what spent the quota: token ledgers, adaptive sampling (`aiuse sample`), `aiuse attribute`.
|
|
517
521
|
- [`packaging/install-deps.sh`](packaging/install-deps.sh) — install the optional data-source tools.
|
|
518
522
|
- [`docs/json-contract.md`](docs/json-contract.md) — stable JSON fields and exit codes.
|
|
519
523
|
- [`docs/provider-identity.md`](docs/provider-identity.md) — canonical provider id vs config key, and window identity across collectors.
|
|
@@ -182,15 +182,59 @@ def test_a_five_hour_window_at_its_ordinary_pace_is_active_not_burst():
|
|
|
182
182
|
|
|
183
183
|
def test_tier_steps_down_one_level_only_after_the_cooldown():
|
|
184
184
|
state = sampler.advance_state({}, _snapshot(T0, {"weekly": 4}), SETTINGS, partial=False)
|
|
185
|
-
|
|
185
|
+
jump = T0 + timedelta(minutes=15)
|
|
186
|
+
state = sampler.advance_state(state, _snapshot(jump, {"weekly": 9}), SETTINGS, partial=False)
|
|
186
187
|
assert state["tier"] == "burst"
|
|
187
|
-
|
|
188
|
-
for n in range(1,
|
|
189
|
-
|
|
190
|
-
state = sampler.advance_state(state,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
188
|
+
tiers = []
|
|
189
|
+
for n in range(1, 6): # flat from here on, sampled every 3 minutes
|
|
190
|
+
flat = _snapshot(jump + timedelta(minutes=3 * n), {"weekly": 9})
|
|
191
|
+
state = sampler.advance_state(state, flat, SETTINGS, partial=True)
|
|
192
|
+
tiers.append((state["tier"], state["quiet_samples"], bool(state["hot"])))
|
|
193
|
+
# Still a burst while the baseline is the pre-jump reading; once the
|
|
194
|
+
# baseline is the jump itself the samples are quiet, and the third quiet
|
|
195
|
+
# one steps down a single tier.
|
|
196
|
+
assert tiers == [
|
|
197
|
+
("burst", 0, True),
|
|
198
|
+
("burst", 0, True),
|
|
199
|
+
("burst", 1, True),
|
|
200
|
+
("burst", 2, True),
|
|
201
|
+
("active", 0, False),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_one_tick_between_close_samples_is_not_a_burst():
|
|
206
|
+
"""Whole-point meters: +1 in 4 minutes reads as 15/h but is ordinary use."""
|
|
207
|
+
state = sampler.advance_state({}, _snapshot(T0, {"weekly": 54}), SETTINGS, partial=False)
|
|
208
|
+
close = _snapshot(T0 + timedelta(minutes=4), {"weekly": 55})
|
|
209
|
+
state = sampler.advance_state(state, close, SETTINGS, partial=False)
|
|
210
|
+
assert state["tier"] == "active" and state["hot"] == []
|
|
211
|
+
# One more tick 15 minutes later is 6.3/h over the 19-minute baseline: still not a burst.
|
|
212
|
+
later = _snapshot(T0 + timedelta(minutes=19), {"weekly": 56})
|
|
213
|
+
state = sampler.advance_state(state, later, SETTINGS, partial=False)
|
|
214
|
+
assert state["tier"] == "active" and state["hot"] == []
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_a_sustained_sprint_stays_in_burst_at_the_three_minute_cadence():
|
|
218
|
+
"""14 points an hour arrives as whole-point ticks; the tier must not flap."""
|
|
219
|
+
state = sampler.advance_state({}, _snapshot(T0, {"weekly": 4}), SETTINGS, partial=False)
|
|
220
|
+
state = sampler.advance_state(
|
|
221
|
+
state, _snapshot(T0 + timedelta(minutes=15), {"weekly": 7.5}), SETTINGS, partial=False
|
|
222
|
+
)
|
|
223
|
+
assert state["tier"] == "burst"
|
|
224
|
+
for n in range(1, 11):
|
|
225
|
+
minutes = 15 + 3 * n
|
|
226
|
+
used = float(int(4 + 14 * minutes / 60)) # the meter shows whole points
|
|
227
|
+
state = sampler.advance_state(
|
|
228
|
+
state, _snapshot(T0 + timedelta(minutes=minutes), {"weekly": used}), SETTINGS, partial=True
|
|
229
|
+
)
|
|
230
|
+
assert state["tier"] == "burst", minutes
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_burst_baseline_does_not_reach_across_a_window_reset():
|
|
234
|
+
state = sampler.advance_state({}, _snapshot(T0, {"5-hour": 5}), SETTINGS, partial=False)
|
|
235
|
+
state = sampler.advance_state(state, _snapshot(T0 + timedelta(minutes=15), {"5-hour": 0}), SETTINGS, partial=False)
|
|
236
|
+
# 0 → 6 in 4 minutes after a reset; the only older reading is from the last cycle.
|
|
237
|
+
state = sampler.advance_state(state, _snapshot(T0 + timedelta(minutes=19), {"5-hour": 6}), SETTINGS, partial=False)
|
|
194
238
|
assert state["tier"] == "active" and state["hot"] == []
|
|
195
239
|
|
|
196
240
|
|
|
@@ -375,6 +419,23 @@ def test_a_fall_after_the_reset_moment_is_a_reset():
|
|
|
375
419
|
assert window["burned_points"] == 5 and window["resets"] == 1
|
|
376
420
|
|
|
377
421
|
|
|
422
|
+
def test_a_window_whose_duration_one_source_omits_is_still_one_series():
|
|
423
|
+
"""CodexBar reports Copilot's premium window without a duration; tokscale with one."""
|
|
424
|
+
|
|
425
|
+
def copilot(when, source, account, minutes, used):
|
|
426
|
+
window = QuotaWindow(label="GitHub Copilot premium requests", used_percent=used, window_minutes=minutes)
|
|
427
|
+
return Snapshot(
|
|
428
|
+
collected_at=when,
|
|
429
|
+
accounts=[AccountUsage(source=source, provider="copilot", account=account, windows=[window])],
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
history.save_snapshot(copilot(T0, "tokscale", None, 43800, 20), [])
|
|
433
|
+
history.save_snapshot(copilot(T0 + timedelta(hours=1), "codexbar", "me (Individual)", None, 22), [])
|
|
434
|
+
report = attribute.build_report({}, T0 - timedelta(minutes=1), T0 + timedelta(hours=2))
|
|
435
|
+
(window,) = report["providers"][0]["windows"]
|
|
436
|
+
assert window["burned_points"] == 2 and window["account"] == "me (Individual)"
|
|
437
|
+
|
|
438
|
+
|
|
378
439
|
def test_report_intervals_line_each_step_up_with_who_spent(tmp_path):
|
|
379
440
|
_seed(tmp_path)
|
|
380
441
|
report = attribute.build_report({}, T0 - timedelta(minutes=1), T0 + timedelta(hours=1))
|
|
@@ -445,3 +445,16 @@ def test_summary_line_names_the_binary_when_it_differs_from_the_provider_id():
|
|
|
445
445
|
assert summary_line(cursor).startswith("cursor auto [cursor-agent]: Cursor Auto: ok")
|
|
446
446
|
codex = {"provider": "codex", "pool_family": None, "cli_binary": "codex", "windows": [window]}
|
|
447
447
|
assert summary_line(codex).startswith("codex: ")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_reset_times_are_local_twelve_hour():
|
|
451
|
+
from datetime import timedelta
|
|
452
|
+
|
|
453
|
+
from aiuse.analysis.selfdescribe import _reset_fragment
|
|
454
|
+
from aiuse.models import utcnow
|
|
455
|
+
from aiuse.report import format_clock
|
|
456
|
+
|
|
457
|
+
soon, later = utcnow() + timedelta(hours=3), utcnow() + timedelta(days=4)
|
|
458
|
+
assert _reset_fragment(soon.isoformat()) == f", resets {format_clock(soon)}"
|
|
459
|
+
assert _reset_fragment(later.isoformat()) == f", resets {format_clock(later, date=True)}"
|
|
460
|
+
assert _reset_fragment(soon.isoformat()).endswith(("am", "pm"))
|
|
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
|