aiuse 3.2.1__tar.gz → 3.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {aiuse-3.2.1/src/aiuse.egg-info → aiuse-3.2.3}/PKG-INFO +5 -1
  2. {aiuse-3.2.1 → aiuse-3.2.3}/README.md +4 -0
  3. {aiuse-3.2.1 → aiuse-3.2.3}/pyproject.toml +1 -1
  4. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/__init__.py +1 -1
  5. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/analysis/selfdescribe.py +4 -4
  6. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/attribute.py +17 -0
  7. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/report.py +10 -2
  8. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/sampler.py +41 -5
  9. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/watch.py +11 -7
  10. {aiuse-3.2.1 → aiuse-3.2.3/src/aiuse.egg-info}/PKG-INFO +5 -1
  11. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_attribution.py +69 -8
  12. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_selfdescribe.py +13 -0
  13. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_watch.py +26 -1
  14. {aiuse-3.2.1 → aiuse-3.2.3}/LICENSE +0 -0
  15. {aiuse-3.2.1 → aiuse-3.2.3}/setup.cfg +0 -0
  16. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/__main__.py +0 -0
  17. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/agent_notes.py +0 -0
  18. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/ai_stub.py +0 -0
  19. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/analysis/__init__.py +0 -0
  20. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/analysis/history.py +0 -0
  21. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/analysis/local_runtimes.py +0 -0
  22. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/analysis/pace.py +0 -0
  23. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/analysis/suggest.py +0 -0
  24. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/analysis/use_or_lose.py +0 -0
  25. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/chat_format.py +0 -0
  26. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/cli.py +0 -0
  27. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/__init__.py +0 -0
  28. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/bailian.py +0 -0
  29. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/base.py +0 -0
  30. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/caut.py +0 -0
  31. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/clinepass.py +0 -0
  32. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/codexbar.py +0 -0
  33. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/cswap.py +0 -0
  34. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/grok.py +0 -0
  35. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/hermes.py +0 -0
  36. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/muse.py +0 -0
  37. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/opencode_go.py +0 -0
  38. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/opencode_zen.py +0 -0
  39. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/openrouter.py +0 -0
  40. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/openusage.py +0 -0
  41. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/openusage_sh.py +0 -0
  42. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/qwencloud.py +0 -0
  43. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/runner.py +0 -0
  44. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/collectors/tokscale.py +0 -0
  45. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/config.py +0 -0
  46. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/credentials.py +0 -0
  47. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/ledger.py +0 -0
  48. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/macos_trust.py +0 -0
  49. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/models.py +0 -0
  50. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/secretspec.py +0 -0
  51. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/serve.py +0 -0
  52. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/tty.py +0 -0
  53. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/tui/__init__.py +0 -0
  54. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/tui/app.py +0 -0
  55. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse/tui/builders.py +0 -0
  56. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse.egg-info/SOURCES.txt +0 -0
  57. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse.egg-info/dependency_links.txt +0 -0
  58. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse.egg-info/entry_points.txt +0 -0
  59. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse.egg-info/requires.txt +0 -0
  60. {aiuse-3.2.1 → aiuse-3.2.3}/src/aiuse.egg-info/top_level.txt +0 -0
  61. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_bailian.py +0 -0
  62. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_base_run_json.py +0 -0
  63. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_caut_parse.py +0 -0
  64. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_chat_format.py +0 -0
  65. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_cli.py +0 -0
  66. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_clinepass.py +0 -0
  67. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_codexbar_parse.py +0 -0
  68. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_config.py +0 -0
  69. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_credentials.py +0 -0
  70. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_cswap_parse.py +0 -0
  71. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_grok.py +0 -0
  72. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_hermes_parse.py +0 -0
  73. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_history.py +0 -0
  74. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_install_deps.py +0 -0
  75. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_local_runtimes.py +0 -0
  76. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_macos_trust.py +0 -0
  77. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_models_cli_binary.py +0 -0
  78. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_models_pace.py +0 -0
  79. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_muse.py +0 -0
  80. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_opencode_go.py +0 -0
  81. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_opencode_zen.py +0 -0
  82. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_openrouter.py +0 -0
  83. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_openusage_parse.py +0 -0
  84. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_pace.py +0 -0
  85. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_qwencloud.py +0 -0
  86. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_release_script.py +0 -0
  87. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_report.py +0 -0
  88. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_runner_consolidation.py +0 -0
  89. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_serve.py +0 -0
  90. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_shared_quota_semantics.py +0 -0
  91. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_snapshots_persistence.py +0 -0
  92. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_suggest.py +0 -0
  93. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_tokscale_parse.py +0 -0
  94. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_tty.py +0 -0
  95. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_tui.py +0 -0
  96. {aiuse-3.2.1 → aiuse-3.2.3}/tests/test_use_or_lose.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.2.1
3
+ Version: 3.2.3
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiuse"
7
- version = "3.2.1"
7
+ version = "3.2.3"
8
8
  description = "Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Aggregate provider quotas and flag use-it-or-lose-it allotments."""
2
2
 
3
- __version__ = "3.2.1"
3
+ __version__ = "3.2.3"
@@ -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
- local = dt.astimezone()
310
- if dt - utcnow() <= timedelta(hours=24):
311
- return f", resets {local.strftime('%H:%M')}"
312
- return f", resets {local.strftime('%m-%d %H:%M')}"
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
@@ -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.isoformat()}"
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.isoformat()}"
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 ''}"
@@ -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 < _MIN_RATE_SECONDS or delta < settings["min_move_percent"]:
164
- continue # too close to judge, flat, or a reset (usage fell)
165
- per_hour = delta / (seconds / 3600.0)
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 = (delta / 100.0) / ((seconds / 60.0) / float(minutes)) if minutes else float("inf")
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:
@@ -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
@@ -188,10 +188,10 @@ def render_watch_board(
188
188
  """
189
189
  header_bits = ["aiuse watch"]
190
190
  if now is not None:
191
- header_bits.append(f"now: {now.astimezone().strftime('%H:%M:%S')}")
191
+ header_bits.append(f"now: {format_clock(now, seconds=True)}")
192
192
  data_at = snapshot.collected_at if snapshot is not None else last_at
193
193
  if data_at is not None:
194
- header_bits.append(f"last: {data_at.astimezone().strftime('%H:%M:%S')}")
194
+ header_bits.append(f"last: {format_clock(data_at, seconds=True)}")
195
195
  else:
196
196
  header_bits.append("last: —")
197
197
  if collecting_for is not None:
@@ -205,17 +205,21 @@ def render_watch_board(
205
205
  previous, due, tier = sample_schedule
206
206
  overdue = " (due)" if now is not None and due <= now else ""
207
207
  lines.append(
208
- f"sampler: previous {previous.astimezone().strftime('%H:%M:%S')}"
209
- f" · next {due.astimezone().strftime('%H:%M:%S')}{overdue} · {tier} tier"
208
+ f"sampler: previous {format_clock(previous, seconds=True)}"
209
+ f" · next {format_clock(due, seconds=True)}{overdue} · {tier} tier"
210
210
  )
211
211
  if error:
212
212
  lines.append(f"collect error: {error}")
213
213
  if snapshot is not None:
214
- lines.append(render_clock_matrix(alerts, snapshot=snapshot, config=config, color=color).rstrip())
214
+ matrix = render_clock_matrix(alerts, snapshot=snapshot, config=config, color=color).rstrip()
215
+ lines.append(matrix)
215
216
  if not quiet:
216
217
  footer = render_stderr_meta(snapshot, alerts, color=color).rstrip()
217
218
  if footer:
218
- lines.append(footer)
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)
219
223
  else:
220
224
  lines.append("waiting for first collection…")
221
225
  return "\n".join(lines)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.2.1
3
+ Version: 3.2.3
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
- state = sampler.advance_state(state, _snapshot(T0 + timedelta(minutes=15), {"weekly": 9}), SETTINGS, partial=False)
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
- when = T0 + timedelta(minutes=15)
188
- for n in range(1, 3):
189
- when += timedelta(minutes=3)
190
- state = sampler.advance_state(state, _snapshot(when, {"weekly": 9}), SETTINGS, partial=True)
191
- assert state["tier"] == "burst" and state["quiet_samples"] == n
192
- assert state["hot"], "keeps watching the burning provider while still in burst"
193
- state = sampler.advance_state(state, _snapshot(when + timedelta(minutes=3), {"weekly": 9}), SETTINGS, partial=True)
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"))
@@ -344,6 +344,7 @@ def test_board_header_shows_now_data_time_and_the_sampler_schedule():
344
344
  from datetime import datetime, timedelta, timezone
345
345
 
346
346
  from aiuse import sampler
347
+ from aiuse.report import format_clock
347
348
  from aiuse.watch import render_watch_board
348
349
 
349
350
  snap = _snap()
@@ -356,7 +357,7 @@ def test_board_header_shows_now_data_time_and_the_sampler_schedule():
356
357
  header, sampler_line = board.splitlines()[:2]
357
358
 
358
359
  def hms(value):
359
- return value.astimezone().strftime("%H:%M:%S")
360
+ return format_clock(value, seconds=True)
360
361
 
361
362
  assert f"now: {hms(now)}" in header and f"last: {hms(snap.collected_at)}" in header
362
363
  assert sampler_line == f"sampler: previous {hms(snap.collected_at)} · next {hms(schedule[1])} · active tier"
@@ -393,3 +394,27 @@ def test_watch_frame_overlays_newer_burst_samples_on_the_full_snapshot(monkeypat
393
394
  used = {a.provider: a.windows[0].used_percent for a in snapshot.accounts}
394
395
  assert used == {"clinepass": 47, "codex": 10}
395
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