aiuse 2.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Aggregate provider quotas and flag use-it-or-lose-it allotments."""
2
+
3
+ __version__ = "2.1.0"
aiuse/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from aiuse.cli import main
2
+
3
+ raise SystemExit(main())
aiuse/ai_stub.py ADDED
@@ -0,0 +1,13 @@
1
+ """Compatibility stub: ``ai`` invokes the same entrypoint as ``aiuse``."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def main() -> int:
7
+ from aiuse.cli import main as aiuse_main
8
+
9
+ return aiuse_main()
10
+
11
+
12
+ if __name__ == "__main__":
13
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Use-it-or-lose-it quota analysis."""
2
+
3
+ from .use_or_lose import analyze_use_or_lose
4
+
5
+ __all__ = ["analyze_use_or_lose"]
@@ -0,0 +1,355 @@
1
+ """Snapshot persistence and consumption-rate learning (Phase 4 — experimental)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime, timedelta, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from aiuse.models import Snapshot, utcnow
12
+
13
+ _DEFAULT_SNAPSHOT_DIR = "~/.cache/aiuse/snapshots"
14
+ _DEFAULT_RETENTION_DAYS = 90
15
+ _DEFAULT_MIN_SNAPSHOTS = 2
16
+ _DEFAULT_LOOKBACK_DAYS = 7
17
+
18
+
19
+ def snapshot_dir() -> Path:
20
+ return Path(os.path.expanduser(_DEFAULT_SNAPSHOT_DIR))
21
+
22
+
23
+ def save_snapshot(snapshot: Snapshot, alerts: list[Any]) -> Path:
24
+ path = snapshot_dir()
25
+ path.mkdir(parents=True, exist_ok=True)
26
+ path.chmod(0o700)
27
+ # Microseconds keep same-second runs unique and still sort lexicographically.
28
+ ts = snapshot.collected_at.strftime("%Y-%m-%dT%H%M%S.%fZ")
29
+ filepath = path / f"{ts}.json"
30
+ n = 1
31
+ while filepath.exists():
32
+ filepath = path / f"{ts}-{n}.json"
33
+ n += 1
34
+ payload = {
35
+ "collected_at": snapshot.collected_at.isoformat(),
36
+ "accounts": [a.to_dict() for a in snapshot.accounts],
37
+ "alerts": [a.to_dict() for a in alerts],
38
+ }
39
+ text = json.dumps(payload, indent=2, default=str) + "\n"
40
+ fd = os.open(filepath, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
41
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
42
+ handle.write(text)
43
+ return filepath
44
+
45
+
46
+ def load_recent_snapshots(
47
+ *, retention_days: int = _DEFAULT_RETENTION_DAYS, max_count: int = 30
48
+ ) -> list[dict[str, Any]]:
49
+ directory = snapshot_dir()
50
+ if not directory.is_dir():
51
+ return []
52
+ cutoff = utcnow() - timedelta(days=retention_days)
53
+ snapshots: list[dict[str, Any]] = []
54
+ for entry in sorted(directory.iterdir(), reverse=True):
55
+ if not entry.is_file() or not entry.suffix.lower() == ".json":
56
+ continue
57
+ try:
58
+ data = json.loads(entry.read_text(encoding="utf-8"))
59
+ except (json.JSONDecodeError, OSError):
60
+ continue
61
+ ts_str = data.get("collected_at")
62
+ if ts_str:
63
+ try:
64
+ collected = datetime.fromisoformat(ts_str)
65
+ if collected.tzinfo is None:
66
+ collected = collected.replace(tzinfo=timezone.utc)
67
+ if collected < cutoff:
68
+ continue
69
+ except ValueError:
70
+ continue
71
+ snapshots.append(data)
72
+ if len(snapshots) >= max_count:
73
+ break
74
+ return snapshots
75
+
76
+
77
+ def _account_window_key(account: dict[str, Any], window: dict[str, Any]) -> str:
78
+ provider = str(account.get("provider", "")).lower()
79
+ acct = str(account.get("account", "")).lower()
80
+ label = str(window.get("label", "")).lower()
81
+ resets = window.get("resets_at") or ""
82
+ return f"{provider}|{acct}|{label}|{resets}"
83
+
84
+
85
+ def compute_learned_burn_rates(
86
+ *,
87
+ current: Snapshot,
88
+ retention_days: int = _DEFAULT_RETENTION_DAYS,
89
+ min_snapshots: int = _DEFAULT_MIN_SNAPSHOTS,
90
+ ) -> dict[str, tuple[float, int]]:
91
+ """Return ``{'provider:duration_kind': (avg_fraction_per_day, sample_count)}``.
92
+
93
+ ``avg_fraction_per_day`` is remaining-percent consumed per day / 100
94
+ (e.g. 0.30 means ~30% of the window per day).
95
+
96
+ Pairs are weighted by elapsed time so multi-minute noise does not dominate
97
+ day-scale samples. Snapshot pairs that straddle a window reset contribute a
98
+ reconstructed tail-of-cycle point instead of being dropped.
99
+ """
100
+ history = load_recent_snapshots(retention_days=retention_days)
101
+ if len(history) < min_snapshots:
102
+ return {}
103
+
104
+ provider_window_burns: dict[str, list[tuple[float, float]]] = {}
105
+
106
+ now = utcnow()
107
+ for prev_data in history:
108
+ ts_str = prev_data.get("collected_at", "")
109
+ try:
110
+ prev_time = datetime.fromisoformat(ts_str)
111
+ if prev_time.tzinfo is None:
112
+ prev_time = prev_time.replace(tzinfo=timezone.utc)
113
+ except ValueError:
114
+ continue
115
+
116
+ time_delta_days = max(0.01, (now - prev_time).total_seconds() / 86400.0)
117
+ if time_delta_days > retention_days:
118
+ continue
119
+ # Weight by elapsed days (capped at 1) so 3-minute pairs barely matter.
120
+ weight = min(time_delta_days, 1.0)
121
+
122
+ for prev_account in prev_data.get("accounts") or []:
123
+ provider = str(prev_account.get("provider", "")).lower()
124
+ for prev_window in prev_account.get("windows") or []:
125
+ prev_remaining = prev_window.get("remaining_percent")
126
+ if prev_remaining is None:
127
+ prev_remaining = _remaining_from_used(prev_window.get("used_percent"))
128
+ if prev_remaining is None:
129
+ continue
130
+ try:
131
+ prev_remaining_f = float(prev_remaining)
132
+ except (TypeError, ValueError):
133
+ continue
134
+
135
+ current_remaining = _find_current_remaining(
136
+ current, prev_account, prev_window, match_resets=True
137
+ )
138
+ burn_rate: float | None = None
139
+ pair_weight = weight
140
+
141
+ if current_remaining is not None:
142
+ consumed = prev_remaining_f - current_remaining
143
+ if consumed > 0:
144
+ burn_rate = consumed / time_delta_days
145
+ elif current_remaining > prev_remaining_f:
146
+ # Same resets_at match but remaining rose — ignore.
147
+ continue
148
+ else:
149
+ continue
150
+ else:
151
+ # No same-cycle match: try same label (possible reset).
152
+ loose = _find_current_remaining(
153
+ current, prev_account, prev_window, match_resets=False
154
+ )
155
+ if loose is None or loose <= prev_remaining_f:
156
+ continue
157
+ # Reset between snapshots: attribute remaining at prev as
158
+ # consumption closed out over prev → previous resets_at.
159
+ resets_raw = prev_window.get("resets_at")
160
+ days_to_reset = time_delta_days
161
+ if resets_raw:
162
+ try:
163
+ resets_at = datetime.fromisoformat(str(resets_raw).replace("Z", "+00:00"))
164
+ if resets_at.tzinfo is None:
165
+ resets_at = resets_at.replace(tzinfo=timezone.utc)
166
+ if prev_time < resets_at <= now:
167
+ days_to_reset = max(0.01, (resets_at - prev_time).total_seconds() / 86400.0)
168
+ except ValueError:
169
+ pass
170
+ burn_rate = prev_remaining_f / days_to_reset
171
+ pair_weight = min(days_to_reset, 1.0)
172
+
173
+ if burn_rate is None:
174
+ continue
175
+ window_minutes = prev_window.get("window_minutes")
176
+ duration_key = _duration_key(window_minutes)
177
+ if duration_key:
178
+ pk = f"{provider}:{duration_key}"
179
+ provider_window_burns.setdefault(pk, []).append((burn_rate, pair_weight))
180
+
181
+ rates: dict[str, tuple[float, int]] = {}
182
+ for pk, burns in provider_window_burns.items():
183
+ if len(burns) < 2:
184
+ continue
185
+ total_w = sum(w for _, w in burns)
186
+ if total_w <= 0:
187
+ continue
188
+ avg_burn_pct = sum(b * w for b, w in burns) / total_w
189
+ rates[pk] = (avg_burn_pct / 100.0, len(burns))
190
+ return rates
191
+
192
+
193
+ def compute_learned_flexibility(
194
+ *,
195
+ current: Snapshot,
196
+ retention_days: int = _DEFAULT_RETENTION_DAYS,
197
+ min_snapshots: int = _DEFAULT_MIN_SNAPSHOTS,
198
+ ) -> dict[str, float]:
199
+ rates = compute_learned_burn_rates(
200
+ current=current,
201
+ retention_days=retention_days,
202
+ min_snapshots=min_snapshots,
203
+ )
204
+ return {k: _burn_rate_to_flexibility(rate * 100.0) for k, (rate, _n) in rates.items()}
205
+
206
+
207
+ def _burn_rate_to_flexibility(burn_rate_pct_per_day: float) -> float:
208
+ if burn_rate_pct_per_day >= 80:
209
+ return 1.0
210
+ if burn_rate_pct_per_day >= 40:
211
+ return 0.7 + 0.3 * (burn_rate_pct_per_day - 40) / 40
212
+ if burn_rate_pct_per_day >= 10:
213
+ return 0.25 + 0.45 * (burn_rate_pct_per_day - 10) / 30
214
+ if burn_rate_pct_per_day >= 2:
215
+ return 0.05 + 0.20 * (burn_rate_pct_per_day - 2) / 8
216
+ return 0.0
217
+
218
+
219
+ def merge_learned_flexibility(
220
+ base_flex: float,
221
+ provider: str,
222
+ duration_kind: str | None,
223
+ learned: dict[str, float],
224
+ ) -> float:
225
+ if not duration_kind or not learned:
226
+ return base_flex
227
+ key = f"{provider.lower().replace(' ', '-')}:{duration_kind}"
228
+ learned_flex = learned.get(key)
229
+ # Exact provider match only — never blend another provider's rate for the
230
+ # same duration bucket (Grok weekly ≠ Codex weekly).
231
+ if learned_flex is None:
232
+ return base_flex
233
+ return 0.3 * learned_flex + 0.7 * base_flex
234
+
235
+
236
+ def chronic_waste_summary(
237
+ *,
238
+ current: Snapshot,
239
+ retention_days: int = _DEFAULT_RETENTION_DAYS,
240
+ ) -> list[dict[str, Any]]:
241
+ history = load_recent_snapshots(retention_days=retention_days)
242
+ if len(history) < _DEFAULT_MIN_SNAPSHOTS:
243
+ return []
244
+
245
+ # samples: list of (resets_at_key, remaining) — at most one per cycle
246
+ wasted: dict[str, dict[str, Any]] = {}
247
+
248
+ for prev_data in history[:7]:
249
+ ts_str = prev_data.get("collected_at", "")
250
+ try:
251
+ prev_time = datetime.fromisoformat(ts_str)
252
+ if prev_time.tzinfo is None:
253
+ prev_time = prev_time.replace(tzinfo=timezone.utc)
254
+ except ValueError:
255
+ continue
256
+
257
+ for prev_account in prev_data.get("accounts") or []:
258
+ provider = str(prev_account.get("provider", "")).lower()
259
+ for prev_window in prev_account.get("windows") or []:
260
+ window_minutes = prev_window.get("window_minutes")
261
+ if not window_minutes or window_minutes > 360:
262
+ continue
263
+ prev_remaining = prev_window.get("remaining_percent")
264
+ if prev_remaining is None:
265
+ prev_remaining = _remaining_from_used(prev_window.get("used_percent"))
266
+ if prev_remaining is None:
267
+ continue
268
+
269
+ key = f"{provider}:{prev_window.get('label', '')}"
270
+ resets_key = str(prev_window.get("resets_at") or "") or f"unknown:{ts_str}"
271
+ bucket = wasted.setdefault(
272
+ key,
273
+ {
274
+ "provider": provider,
275
+ "label": prev_window.get("label", ""),
276
+ "by_reset": {}, # resets_at -> remaining (most recent wins)
277
+ },
278
+ )
279
+ # history is newest-first; keep first sample per resets_at
280
+ if resets_key not in bucket["by_reset"]:
281
+ bucket["by_reset"][resets_key] = float(prev_remaining)
282
+
283
+ result: list[dict[str, Any]] = []
284
+ for key, data in wasted.items():
285
+ by_reset: dict[str, float] = data["by_reset"]
286
+ if len(by_reset) < 2:
287
+ continue
288
+ samples = list(by_reset.values())
289
+ avg = sum(samples) / len(samples)
290
+ result.append(
291
+ {
292
+ "provider": data["provider"],
293
+ "label": data["label"],
294
+ "avg_remaining_pct": round(avg, 1),
295
+ "sample_count": len(samples),
296
+ }
297
+ )
298
+ result.sort(key=lambda x: x["avg_remaining_pct"], reverse=True)
299
+ return result
300
+
301
+
302
+ def _remaining_from_used(used: Any) -> float | None:
303
+ if used is None:
304
+ return None
305
+ try:
306
+ return max(0.0, 100.0 - float(used))
307
+ except (TypeError, ValueError):
308
+ return None
309
+
310
+
311
+ def _find_current_remaining(
312
+ snapshot: Snapshot,
313
+ prev_account: dict[str, Any],
314
+ prev_window: dict[str, Any],
315
+ *,
316
+ match_resets: bool = True,
317
+ ) -> float | None:
318
+ prev_provider = str(prev_account.get("provider", "")).lower()
319
+ prev_account_id = str(prev_account.get("account", "")).lower()
320
+ prev_label = str(prev_window.get("label", "")).lower()
321
+ prev_resets = prev_window.get("resets_at") or ""
322
+
323
+ for acc in snapshot.accounts:
324
+ if acc.provider.lower() != prev_provider:
325
+ continue
326
+ if (acc.account or "").lower() != prev_account_id:
327
+ continue
328
+ for w in acc.windows:
329
+ if w.label.lower() != prev_label:
330
+ continue
331
+ if match_resets and prev_resets:
332
+ w_resets = w.resets_at.isoformat() if w.resets_at else ""
333
+ if w_resets != prev_resets:
334
+ continue
335
+ val = w.remaining()
336
+ if val is not None:
337
+ return val
338
+ return _remaining_from_used(w.used_percent)
339
+ return None
340
+
341
+
342
+ def _duration_key(window_minutes: Any) -> str | None:
343
+ if window_minutes is None:
344
+ return None
345
+ try:
346
+ m = int(window_minutes)
347
+ except (TypeError, ValueError):
348
+ return None
349
+ if m <= 360:
350
+ return "5h"
351
+ if m <= 10080:
352
+ return "weekly"
353
+ if m <= 44640:
354
+ return "monthly"
355
+ return None
aiuse/analysis/pace.py ADDED
@@ -0,0 +1,117 @@
1
+ """Pure pace-math for Phase 2 scoring (not wired into analyze_use_or_lose yet)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+
7
+ from aiuse.models import PaceProfile, QuotaWindow, classify_window_minutes, nominal_window_minutes
8
+
9
+
10
+ def compute_pace(
11
+ window: QuotaWindow,
12
+ *,
13
+ now: datetime,
14
+ learned_rate_per_day: float | None = None, # fraction/day, e.g. 0.30 == 30%/day
15
+ learned_sample_count: int = 0,
16
+ e_min: float = 0.05,
17
+ ) -> PaceProfile | None:
18
+ remaining = window.remaining()
19
+ if remaining is None:
20
+ return None
21
+ used_fraction = (100.0 - remaining) / 100.0
22
+
23
+ kind = classify_window_minutes(window.window_minutes)
24
+ duration_minutes = window.window_minutes or nominal_window_minutes(kind)
25
+ confidence = "measured" if window.window_minutes else ("inferred" if duration_minutes else "low")
26
+
27
+ if not window.resets_at or not duration_minutes:
28
+ return PaceProfile(
29
+ elapsed_fraction=None,
30
+ used_fraction=used_fraction,
31
+ pace_ratio=None,
32
+ projected_used_fraction=None,
33
+ projected_waste_fraction=None,
34
+ projected_waste_usd=None,
35
+ projected_exhaust_at=None,
36
+ confidence="low",
37
+ )
38
+
39
+ t_left_days = max(0.0, (window.resets_at - now).total_seconds() / 86400.0)
40
+ d_days = duration_minutes / 1440.0
41
+ elapsed = min(1.0, max(0.0, 1.0 - t_left_days / d_days))
42
+
43
+ r_now = used_fraction / (max(elapsed, e_min) * d_days) # fraction/day
44
+ if learned_rate_per_day is not None and learned_sample_count > 0:
45
+ lam = learned_sample_count / (learned_sample_count + 2.0)
46
+ r_hat = (1 - lam) * r_now + lam * learned_rate_per_day
47
+ else:
48
+ r_hat = r_now
49
+
50
+ projected_used = min(1.0, used_fraction + r_hat * t_left_days)
51
+ waste = 1.0 - projected_used
52
+ exhaust_at = now + timedelta(days=(1.0 - used_fraction) / r_hat) if r_hat > 1e-9 else None
53
+
54
+ return PaceProfile(
55
+ elapsed_fraction=elapsed,
56
+ used_fraction=used_fraction,
57
+ pace_ratio=used_fraction / max(elapsed, e_min),
58
+ projected_used_fraction=projected_used,
59
+ projected_waste_fraction=waste,
60
+ projected_waste_usd=None, # filled in by the caller once it knows the plan price
61
+ projected_exhaust_at=exhaust_at,
62
+ confidence=confidence,
63
+ )
64
+
65
+
66
+ def classify_pace(
67
+ pace: PaceProfile,
68
+ *,
69
+ resets_at: datetime | None,
70
+ waste_alert_fraction: float,
71
+ min_elapsed_fraction: float,
72
+ conserve_min_lead_hours: float,
73
+ has_learned_rate: bool,
74
+ ) -> str:
75
+ """Returns 'conserve' | 'burn' | 'on_pace' | 'unknown'."""
76
+ if pace.projected_waste_fraction is None and pace.projected_exhaust_at is None:
77
+ return "unknown"
78
+ # Too early in the window (no learned rate) → do not trust burn/conserve yet.
79
+ if (
80
+ pace.elapsed_fraction is not None
81
+ and pace.elapsed_fraction < min_elapsed_fraction
82
+ and not has_learned_rate
83
+ ):
84
+ return "on_pace"
85
+ if pace.projected_exhaust_at and resets_at:
86
+ if pace.projected_exhaust_at < resets_at - timedelta(hours=conserve_min_lead_hours):
87
+ return "conserve"
88
+ if pace.projected_waste_fraction is None:
89
+ return "unknown"
90
+ if pace.projected_waste_fraction >= waste_alert_fraction:
91
+ return "burn"
92
+ return "on_pace"
93
+
94
+ def governing_partition(windows: list[QuotaWindow]) -> tuple[QuotaWindow | None, list[QuotaWindow]]:
95
+ """Longest-duration window with usable remaining() governs; the rest are children.
96
+
97
+ When durations tie (e.g. Cursor Included/Auto/API all monthly), prefer a
98
+ window whose label looks like the overall included bar, then list order.
99
+ """
100
+ scored = [
101
+ (
102
+ w.window_minutes
103
+ or nominal_window_minutes(classify_window_minutes(w.window_minutes))
104
+ or 0,
105
+ 0 if "included" in (w.label or "").casefold() else 1,
106
+ w,
107
+ )
108
+ for w in windows
109
+ if w.remaining() is not None
110
+ ]
111
+ if not scored:
112
+ return None, list(windows)
113
+ # Longest minutes first; among ties, included (rank 0) before others.
114
+ scored.sort(key=lambda pair: (-pair[0], pair[1]))
115
+ governing = scored[0][2]
116
+ children = [w for w in windows if w is not governing]
117
+ return governing, children