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.
Files changed (96) hide show
  1. {aiuse-3.2.2/src/aiuse.egg-info → aiuse-3.2.4}/PKG-INFO +5 -1
  2. {aiuse-3.2.2 → aiuse-3.2.4}/README.md +4 -0
  3. {aiuse-3.2.2 → aiuse-3.2.4}/pyproject.toml +1 -1
  4. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/__init__.py +1 -1
  5. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/selfdescribe.py +4 -4
  6. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/attribute.py +17 -0
  7. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/report.py +21 -5
  8. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/sampler.py +41 -5
  9. {aiuse-3.2.2 → aiuse-3.2.4/src/aiuse.egg-info}/PKG-INFO +5 -1
  10. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_attribution.py +69 -8
  11. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_selfdescribe.py +13 -0
  12. {aiuse-3.2.2 → aiuse-3.2.4}/LICENSE +0 -0
  13. {aiuse-3.2.2 → aiuse-3.2.4}/setup.cfg +0 -0
  14. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/__main__.py +0 -0
  15. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/agent_notes.py +0 -0
  16. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/ai_stub.py +0 -0
  17. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/__init__.py +0 -0
  18. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/history.py +0 -0
  19. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/local_runtimes.py +0 -0
  20. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/pace.py +0 -0
  21. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/suggest.py +0 -0
  22. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/analysis/use_or_lose.py +0 -0
  23. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/chat_format.py +0 -0
  24. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/cli.py +0 -0
  25. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/__init__.py +0 -0
  26. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/bailian.py +0 -0
  27. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/base.py +0 -0
  28. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/caut.py +0 -0
  29. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/clinepass.py +0 -0
  30. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/codexbar.py +0 -0
  31. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/cswap.py +0 -0
  32. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/grok.py +0 -0
  33. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/hermes.py +0 -0
  34. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/muse.py +0 -0
  35. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/opencode_go.py +0 -0
  36. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/opencode_zen.py +0 -0
  37. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/openrouter.py +0 -0
  38. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/openusage.py +0 -0
  39. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/openusage_sh.py +0 -0
  40. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/qwencloud.py +0 -0
  41. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/runner.py +0 -0
  42. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/collectors/tokscale.py +0 -0
  43. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/config.py +0 -0
  44. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/credentials.py +0 -0
  45. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/ledger.py +0 -0
  46. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/macos_trust.py +0 -0
  47. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/models.py +0 -0
  48. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/secretspec.py +0 -0
  49. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/serve.py +0 -0
  50. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/tty.py +0 -0
  51. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/tui/__init__.py +0 -0
  52. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/tui/app.py +0 -0
  53. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/tui/builders.py +0 -0
  54. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse/watch.py +0 -0
  55. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/SOURCES.txt +0 -0
  56. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/dependency_links.txt +0 -0
  57. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/entry_points.txt +0 -0
  58. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/requires.txt +0 -0
  59. {aiuse-3.2.2 → aiuse-3.2.4}/src/aiuse.egg-info/top_level.txt +0 -0
  60. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_bailian.py +0 -0
  61. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_base_run_json.py +0 -0
  62. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_caut_parse.py +0 -0
  63. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_chat_format.py +0 -0
  64. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_cli.py +0 -0
  65. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_clinepass.py +0 -0
  66. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_codexbar_parse.py +0 -0
  67. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_config.py +0 -0
  68. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_credentials.py +0 -0
  69. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_cswap_parse.py +0 -0
  70. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_grok.py +0 -0
  71. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_hermes_parse.py +0 -0
  72. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_history.py +0 -0
  73. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_install_deps.py +0 -0
  74. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_local_runtimes.py +0 -0
  75. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_macos_trust.py +0 -0
  76. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_models_cli_binary.py +0 -0
  77. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_models_pace.py +0 -0
  78. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_muse.py +0 -0
  79. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_opencode_go.py +0 -0
  80. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_opencode_zen.py +0 -0
  81. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_openrouter.py +0 -0
  82. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_openusage_parse.py +0 -0
  83. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_pace.py +0 -0
  84. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_qwencloud.py +0 -0
  85. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_release_script.py +0 -0
  86. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_report.py +0 -0
  87. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_runner_consolidation.py +0 -0
  88. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_serve.py +0 -0
  89. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_shared_quota_semantics.py +0 -0
  90. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_snapshots_persistence.py +0 -0
  91. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_suggest.py +0 -0
  92. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_tokscale_parse.py +0 -0
  93. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_tty.py +0 -0
  94. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_tui.py +0 -0
  95. {aiuse-3.2.2 → aiuse-3.2.4}/tests/test_use_or_lose.py +0 -0
  96. {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.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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiuse"
7
- version = "3.2.2"
7
+ version = "3.2.4"
8
8
  description = "Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Aggregate provider quotas and flag use-it-or-lose-it allotments."""
2
2
 
3
- __version__ = "3.2.2"
3
+ __version__ = "3.2.4"
@@ -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
@@ -1517,11 +1517,27 @@ def render_clock_matrix(
1517
1517
  estimated=cell.reset_estimated,
1518
1518
  compact=layout.compact_deadline,
1519
1519
  )
1520
- colored = (
1521
- s.dim(f"{percent:>{w_pct}}")
1522
- if cell.inferred
1523
- else _cell_color(s, cell.used_percent)(f"{percent:>{w_pct}}")
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 < _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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.2.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
- 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"))
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