usage-cli 0.29.32__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.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
agy_quota_probe.py ADDED
@@ -0,0 +1,748 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ """Fetch Antigravity quota straight from the official Cloud Code quota API.
8
+
9
+ Reads the OAuth token that the Antigravity CLI stores in the platform credential
10
+ store (with its legacy token file as fallback), refreshes it through Google's
11
+ token endpoint when stale, then POSTs the internal ``retrieveUserQuotaSummary``
12
+ endpoint for the quota summary. No CLI is spawned, no screen text is parsed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import platform
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ import time
25
+ import urllib.parse
26
+ from base64 import b64decode
27
+ from binascii import Error as Base64Error
28
+ from contextlib import suppress
29
+ from dataclasses import dataclass
30
+ from datetime import UTC, datetime, timedelta
31
+ from email.utils import parsedate_to_datetime
32
+ from pathlib import Path
33
+ from typing import Any
34
+ from urllib.error import HTTPError, URLError
35
+ from urllib.request import Request, urlopen
36
+
37
+ CACHE_PATH = Path(os.path.expanduser("~/.usage/agy_quota_cache.json"))
38
+ # Legacy OAuth token written by older Antigravity CLI versions. Read-only: we
39
+ # never write back here (that is the CLI's home and could corrupt its login).
40
+ _TOKEN_PATH = Path(os.path.expanduser("~/.gemini/antigravity-cli/antigravity-oauth-token"))
41
+ _KEYCHAIN_SERVICE = "gemini"
42
+ _KEYCHAIN_ACCOUNT = "antigravity"
43
+ _KEYRING_BASE64_PREFIX = "go-keyring-base64:"
44
+
45
+ _TOKEN_URL = "https://oauth2.googleapis.com/token"
46
+ _QUOTA_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"
47
+ # Both endpoints return small JSON objects; cap the read so a broken endpoint
48
+ # cannot make us buffer an unbounded response.
49
+ _MAX_RESPONSE_BYTES = 4 * 1024 * 1024
50
+ # Antigravity's installed-app public client constants (same values quotio ships).
51
+ # Plain text on purpose: RFC 8252 installed apps cannot keep a client secret
52
+ # confidential, so this is a public client, not a security boundary. It is not
53
+ # the user's credential and grants nothing on its own -- the authorization comes
54
+ # from the OAuth token already on their machine. Secret scanners flag the
55
+ # GOCSPX- prefix here by design; see SECURITY.md. If Google rotates these, the
56
+ # token request fails, the probe returns None, and only Antigravity quota
57
+ # disappears.
58
+ _CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
59
+ _CLIENT_SECRET = "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"
60
+
61
+ _DEFAULT_RETRY_AFTER_SECONDS = 60.0
62
+ _MAX_RETRY_AFTER_SECONDS = 3600.0
63
+ _rate_limit_until_monotonic = 0.0
64
+
65
+
66
+ def _user_agent() -> str:
67
+ """Build the Antigravity client identifier for the host platform."""
68
+ return f"antigravity/1.11.3 {platform.system()}/{platform.machine()}"
69
+
70
+
71
+ _USER_AGENT = _user_agent()
72
+
73
+ # In-memory cache of a refreshed access token so each probe does not re-refresh.
74
+ # Holds {"access_token": str, "expires_monotonic": float}.
75
+ _token_cache: dict[str, object] = {}
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class AgyQuotaWindow:
80
+ """Remaining quota and its reported reset countdown."""
81
+
82
+ remaining_percent: float
83
+ resets_in: str | None
84
+ resets_in_minutes: int | None
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class AgyQuotaGroup:
89
+ """Quota windows shared by a named group of Antigravity models."""
90
+
91
+ name: str
92
+ models: list[str]
93
+ weekly: AgyQuotaWindow
94
+ five_hour: AgyQuotaWindow
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class AgyQuotaResult:
99
+ """All model groups returned by one quota probe."""
100
+
101
+ groups: list[AgyQuotaGroup]
102
+ fetched_at: str
103
+
104
+
105
+ def probe_quota(timeout_seconds: float = 15) -> AgyQuotaResult | None:
106
+ """Fetch the quota summary via the Cloud Code API, returning no result on failure."""
107
+ if timeout_seconds <= 0:
108
+ return None
109
+ access_token = _resolve_access_token(timeout_seconds)
110
+ if access_token is None:
111
+ return None
112
+ raw = _post_json(_QUOTA_URL, access_token, {}, timeout_seconds)
113
+ if raw is None:
114
+ return None
115
+ groups_raw = _extract_groups(raw)
116
+ if not groups_raw:
117
+ return None
118
+ return _build_result(groups_raw)
119
+
120
+
121
+ def find_agy() -> str | None:
122
+ """Find the Antigravity CLI in PATH or common user installation paths."""
123
+ path = shutil.which("agy")
124
+ if path is not None:
125
+ return path
126
+ for candidate in ("~/.local/bin/agy", "/opt/homebrew/bin/agy", "/usr/local/bin/agy"):
127
+ expanded = os.path.expanduser(candidate)
128
+ if os.access(expanded, os.X_OK):
129
+ return expanded
130
+ return None
131
+
132
+
133
+ def load_quota(max_age_minutes: float = 5) -> AgyQuotaResult | None:
134
+ """Return fresh cached quota, otherwise probe and preserve stale fallback."""
135
+ cached = _read_cache()
136
+ if cached is not None and _is_fresh(cached, max_age_minutes):
137
+ return cached
138
+ if time.monotonic() < _rate_limit_until_monotonic:
139
+ return cached
140
+
141
+ probed = probe_quota()
142
+ if probed is None:
143
+ return cached
144
+ _write_cache(probed)
145
+ return probed
146
+
147
+
148
+ def _resolve_access_token(timeout: float) -> str | None:
149
+ """Return a usable access token, refreshing when stale.
150
+
151
+ Prefer a refreshed token still held in memory, then the on-disk token if its
152
+ expiry is comfortably in the future, otherwise refresh via the token
153
+ endpoint. Falls back to the on-disk access token (possibly expired) only if a
154
+ refresh is impossible, so a 401 surfaces naturally.
155
+ """
156
+ cached_token = _token_cache.get("access_token")
157
+ cached_expiry = _token_cache.get("expires_monotonic")
158
+ if (
159
+ isinstance(cached_token, str)
160
+ and isinstance(cached_expiry, (int, float))
161
+ and time.monotonic() < float(cached_expiry) - 60
162
+ ):
163
+ return cached_token
164
+
165
+ token_data: object = None
166
+ if sys.platform == "darwin":
167
+ token_data = _read_macos_credential()
168
+ if not _has_usable_token(token_data):
169
+ token_data = _read_token_file()
170
+ if not _has_usable_token(token_data) and sys.platform == "win32":
171
+ token_data = _read_windows_credential()
172
+ if not isinstance(token_data, dict):
173
+ return None
174
+ token = token_data.get("token")
175
+ if not isinstance(token, dict):
176
+ return None
177
+ access_token = token.get("access_token")
178
+ expiry = token.get("expiry")
179
+ if (
180
+ isinstance(access_token, str)
181
+ and isinstance(expiry, str)
182
+ and _seconds_until(expiry) > 60
183
+ ):
184
+ return access_token
185
+
186
+ refresh_token = token.get("refresh_token")
187
+ if not isinstance(refresh_token, str) or not refresh_token:
188
+ return access_token if isinstance(access_token, str) else None
189
+ refreshed = _refresh_token(refresh_token, timeout)
190
+ if refreshed is None:
191
+ return access_token if isinstance(access_token, str) else None
192
+ new_token, expires_in = refreshed
193
+ _token_cache["access_token"] = new_token
194
+ _token_cache["expires_monotonic"] = time.monotonic() + float(expires_in)
195
+ return new_token
196
+
197
+
198
+ def _read_token_file() -> object:
199
+ """Read and parse the Antigravity OAuth token file (never writes back)."""
200
+ try:
201
+ with _TOKEN_PATH.open(encoding="utf-8") as handle:
202
+ return json.load(handle)
203
+ except (OSError, ValueError):
204
+ return None
205
+
206
+
207
+ def _read_macos_credential() -> object:
208
+ """Read the current Antigravity OAuth credential from macOS Keychain."""
209
+ if sys.platform != "darwin":
210
+ return None
211
+ try:
212
+ completed = subprocess.run(
213
+ [
214
+ "security",
215
+ "find-generic-password",
216
+ "-a",
217
+ _KEYCHAIN_ACCOUNT,
218
+ "-s",
219
+ _KEYCHAIN_SERVICE,
220
+ "-w",
221
+ ],
222
+ check=False,
223
+ capture_output=True,
224
+ timeout=5,
225
+ )
226
+ except (OSError, subprocess.SubprocessError):
227
+ return None
228
+ if completed.returncode != 0:
229
+ return None
230
+ return _parse_keyring_secret(completed.stdout)
231
+
232
+
233
+ def _parse_keyring_secret(secret: bytes) -> object:
234
+ """Parse JSON stored directly or through go-keyring's Base64 wrapper."""
235
+ prefix = _KEYRING_BASE64_PREFIX.encode("ascii")
236
+ payload = secret.strip()
237
+ if payload.startswith(prefix):
238
+ try:
239
+ payload = b64decode(payload[len(prefix) :], validate=True)
240
+ except (Base64Error, ValueError):
241
+ return None
242
+ try:
243
+ return json.loads(payload.decode("utf-8"))
244
+ except (UnicodeDecodeError, ValueError):
245
+ return None
246
+
247
+
248
+ def _has_usable_token(token_data: object) -> bool:
249
+ """Return whether a token payload has the fields needed to resolve access."""
250
+ if not isinstance(token_data, dict):
251
+ return False
252
+ token = token_data.get("token")
253
+ if not isinstance(token, dict):
254
+ return False
255
+ access_token = token.get("access_token")
256
+ expiry = token.get("expiry")
257
+ return (
258
+ isinstance(access_token, str)
259
+ and bool(access_token)
260
+ and isinstance(expiry, str)
261
+ and _parse_timestamp(expiry) is not None
262
+ )
263
+
264
+
265
+ def _windows_ctypes() -> tuple[Any, Any]:
266
+ """Lazily import Windows-only ctypes helpers so this module stays portable."""
267
+ import ctypes
268
+ from ctypes import wintypes
269
+
270
+ return ctypes, wintypes
271
+
272
+
273
+ def _read_windows_credential() -> object:
274
+ """Read the Antigravity OAuth credential from Windows Credential Manager.
275
+
276
+ Read-only: we never write back to Credential Manager (that is the CLI's
277
+ home and changing it would risk corrupting its login).
278
+ """
279
+ if sys.platform != "win32":
280
+ return None
281
+
282
+ ctypes, wintypes = _windows_ctypes()
283
+
284
+ class _Credential(ctypes.Structure): # type: ignore[name-defined, misc]
285
+ _fields_ = [
286
+ ("Flags", wintypes.DWORD),
287
+ ("Type", wintypes.DWORD),
288
+ ("TargetName", wintypes.LPWSTR),
289
+ ("Comment", wintypes.LPWSTR),
290
+ ("LastWritten", wintypes.FILETIME),
291
+ ("CredentialBlobSize", wintypes.DWORD),
292
+ ("CredentialBlob", ctypes.POINTER(wintypes.BYTE)),
293
+ ("Persist", wintypes.DWORD),
294
+ ("AttributeCount", wintypes.DWORD),
295
+ ("Attributes", wintypes.LPVOID),
296
+ ("TargetAlias", wintypes.LPWSTR),
297
+ ("UserName", wintypes.LPWSTR),
298
+ ]
299
+
300
+ credential = ctypes.POINTER(_Credential)()
301
+ cred_read = ctypes.windll.advapi32.CredReadW
302
+ cred_read.argtypes = [
303
+ wintypes.LPCWSTR,
304
+ wintypes.DWORD,
305
+ wintypes.DWORD,
306
+ ctypes.POINTER(ctypes.POINTER(_Credential)),
307
+ ]
308
+ cred_read.restype = wintypes.BOOL
309
+ if not cred_read("gemini:antigravity", 1, 0, ctypes.byref(credential)):
310
+ return None
311
+ try:
312
+ size = credential.contents.CredentialBlobSize
313
+ blob = ctypes.string_at(credential.contents.CredentialBlob, size)
314
+ finally:
315
+ ctypes.windll.advapi32.CredFree(credential)
316
+ return _parse_windows_credential_blob(blob)
317
+
318
+
319
+ def _parse_windows_credential_blob(blob: bytes) -> object:
320
+ """Parse a CredentialBlob, normally UTF-16LE JSON written by the CLI."""
321
+ parsed = _parse_keyring_secret(blob)
322
+ if parsed is not None:
323
+ return parsed
324
+ for encoding in ("utf-16-le", "utf-8"):
325
+ try:
326
+ return json.loads(blob.decode(encoding).lstrip("\ufeff").rstrip("\x00"))
327
+ except (UnicodeDecodeError, ValueError):
328
+ continue
329
+ return None
330
+
331
+
332
+ def _refresh_token(refresh_token: str, timeout: float) -> tuple[str, int] | None:
333
+ """Exchange a refresh token for a fresh access token via the token endpoint."""
334
+ body = urllib.parse.urlencode(
335
+ {
336
+ "client_id": _CLIENT_ID,
337
+ "client_secret": _CLIENT_SECRET,
338
+ "refresh_token": refresh_token,
339
+ "grant_type": "refresh_token",
340
+ }
341
+ ).encode("utf-8")
342
+ request = Request(
343
+ _TOKEN_URL,
344
+ data=body,
345
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
346
+ method="POST",
347
+ )
348
+ try:
349
+ with urlopen(request, timeout=timeout) as response:
350
+ raw = response.read(_MAX_RESPONSE_BYTES + 1)
351
+ if len(raw) > _MAX_RESPONSE_BYTES:
352
+ raise ValueError("token response exceeds the size limit")
353
+ payload = json.loads(raw)
354
+ except HTTPError as exc:
355
+ _handle_http_error(exc)
356
+ return None
357
+ except (URLError, OSError, ValueError):
358
+ return None
359
+ if not isinstance(payload, dict):
360
+ return None
361
+ access_token = payload.get("access_token")
362
+ expires_in = payload.get("expires_in")
363
+ if not isinstance(access_token, str) or isinstance(expires_in, bool):
364
+ return None
365
+ if not isinstance(expires_in, (int, float)):
366
+ return None
367
+ return access_token, int(expires_in)
368
+
369
+
370
+ def _post_json(url: str, access_token: str, body: dict[str, object], timeout: float) -> object:
371
+ """POST a JSON body with the bearer token; return parsed JSON or None on failure."""
372
+ request = Request(
373
+ url,
374
+ data=json.dumps(body).encode("utf-8"),
375
+ headers={
376
+ "Authorization": f"Bearer {access_token}",
377
+ "User-Agent": _USER_AGENT,
378
+ "Content-Type": "application/json",
379
+ },
380
+ method="POST",
381
+ )
382
+ try:
383
+ with urlopen(request, timeout=timeout) as response:
384
+ raw = response.read(_MAX_RESPONSE_BYTES + 1)
385
+ if len(raw) > _MAX_RESPONSE_BYTES:
386
+ raise ValueError("quota response exceeds the size limit")
387
+ return json.loads(raw)
388
+ except HTTPError as exc:
389
+ _handle_http_error(exc)
390
+ return None
391
+ except (URLError, OSError, ValueError):
392
+ return None
393
+
394
+
395
+ def _handle_http_error(exc: HTTPError) -> None:
396
+ """Start a bounded in-memory backoff after an HTTP 429 response."""
397
+ if exc.code != 429:
398
+ return
399
+ retry_after = exc.headers.get("Retry-After") if exc.headers is not None else None
400
+ _set_rate_limit_backoff(retry_after)
401
+
402
+
403
+ def _set_rate_limit_backoff(retry_after: str | None) -> None:
404
+ global _rate_limit_until_monotonic
405
+
406
+ _rate_limit_until_monotonic = time.monotonic() + _parse_retry_after(retry_after)
407
+
408
+
409
+ def _parse_retry_after(retry_after: str | None) -> float:
410
+ """Convert Retry-After seconds or an HTTP-date to a bounded delay."""
411
+ delay = _DEFAULT_RETRY_AFTER_SECONDS
412
+ if isinstance(retry_after, str):
413
+ value = retry_after.strip()
414
+ if value.isdigit():
415
+ with suppress(ValueError, OverflowError):
416
+ delay = float(value)
417
+ elif value:
418
+ try:
419
+ retry_at = parsedate_to_datetime(value)
420
+ if retry_at.tzinfo is None:
421
+ retry_at = retry_at.replace(tzinfo=UTC)
422
+ delay = (retry_at.astimezone(UTC) - datetime.now(UTC)).total_seconds()
423
+ except (TypeError, ValueError, OverflowError):
424
+ pass
425
+ return max(0.0, min(delay, _MAX_RETRY_AFTER_SECONDS))
426
+
427
+
428
+ def _extract_groups(raw: object) -> list[dict[str, object]] | None:
429
+ """Pull the groups array from any of its three known nesting locations."""
430
+ if not isinstance(raw, dict):
431
+ return None
432
+ direct = raw.get("groups")
433
+ if isinstance(direct, list):
434
+ return [group for group in direct if isinstance(group, dict)]
435
+ for wrapper_key in ("response", "summary"):
436
+ wrapper = raw.get(wrapper_key)
437
+ if isinstance(wrapper, dict):
438
+ inner = wrapper.get("groups")
439
+ if isinstance(inner, list):
440
+ return [group for group in inner if isinstance(group, dict)]
441
+ return None
442
+
443
+
444
+ def _build_result(groups_raw: list[dict[str, object]]) -> AgyQuotaResult | None:
445
+ """Classify buckets into the Gemini / Claude-GPT groups UI expects."""
446
+ windows: dict[str, dict[str, AgyQuotaWindow]] = {}
447
+ for group in groups_raw:
448
+ group_id = _group_id(group)
449
+ if group_id is None:
450
+ continue
451
+ buckets = group.get("buckets")
452
+ if not isinstance(buckets, list):
453
+ continue
454
+ slot = windows.setdefault(group_id, {})
455
+ for bucket in buckets:
456
+ if not isinstance(bucket, dict) or _bucket_disabled(bucket):
457
+ continue
458
+ period = _bucket_period(bucket)
459
+ if period is None or period in slot:
460
+ continue
461
+ window = _bucket_to_window(bucket)
462
+ if window is None:
463
+ continue
464
+ slot[period] = window
465
+ result_groups = _assemble_groups(windows)
466
+ if not result_groups:
467
+ return None
468
+ return AgyQuotaResult(groups=result_groups, fetched_at=datetime.now(UTC).isoformat())
469
+
470
+
471
+ def _assemble_groups(
472
+ windows: dict[str, dict[str, AgyQuotaWindow]],
473
+ ) -> list[AgyQuotaGroup]:
474
+ """Lay out the two UI groups in fixed order, filling missing windows as full."""
475
+ full = AgyQuotaWindow(100.0, None, None)
476
+ layout = (
477
+ ("gemini", "GEMINI MODELS", ["Gemini Flash", "Gemini Pro"]),
478
+ ("claude-gpt", "CLAUDE AND GPT MODELS", ["Claude Opus", "Claude Sonnet", "GPT-OSS"]),
479
+ )
480
+ groups: list[AgyQuotaGroup] = []
481
+ for group_id, name, models in layout:
482
+ slot = windows.get(group_id)
483
+ if slot is None:
484
+ continue
485
+ groups.append(
486
+ AgyQuotaGroup(
487
+ name=name,
488
+ models=list(models),
489
+ weekly=slot.get("weekly", full),
490
+ five_hour=slot.get("session", full),
491
+ )
492
+ )
493
+ return groups
494
+
495
+
496
+ def _group_id(group: dict[str, object]) -> str | None:
497
+ name = _coerce_str(group.get("displayName")) or _coerce_str(group.get("name"))
498
+ if name is None:
499
+ return None
500
+ lower = name.lower()
501
+ if "gemini" in lower:
502
+ return "gemini"
503
+ if "claude" in lower or "gpt" in lower:
504
+ return "claude-gpt"
505
+ return None
506
+
507
+
508
+ def _bucket_period(bucket: dict[str, object]) -> str | None:
509
+ label = " ".join(
510
+ [
511
+ _coerce_str(bucket.get("bucketId")) or "",
512
+ _coerce_str(bucket.get("id")) or "",
513
+ _coerce_str(bucket.get("displayName")) or "",
514
+ _coerce_str(bucket.get("name")) or "",
515
+ _coerce_str(bucket.get("window")) or "",
516
+ ]
517
+ ).lower()
518
+ if "week" in label or "7d" in label or "seven" in label:
519
+ return "weekly"
520
+ if "session" in label or "5" in label or "hour" in label:
521
+ return "session"
522
+ return None
523
+
524
+
525
+ def _bucket_disabled(bucket: dict[str, object]) -> bool:
526
+ value = bucket.get("disabled")
527
+ if isinstance(value, bool):
528
+ return value
529
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
530
+ return value != 0
531
+ text = _coerce_str(value)
532
+ return text is not None and text.lower() in {"true", "1"}
533
+
534
+
535
+ def _bucket_to_window(bucket: dict[str, object]) -> AgyQuotaWindow | None:
536
+ fraction = _remaining_fraction(bucket)
537
+ if fraction is None:
538
+ return None
539
+ remaining = max(0.0, min(1.0, fraction)) * 100.0
540
+ reset_time = _bucket_reset_time(bucket)
541
+ if remaining >= 100.0 and reset_time is None:
542
+ return AgyQuotaWindow(100.0, None, None)
543
+ if reset_time is None:
544
+ return AgyQuotaWindow(remaining, None, None)
545
+ minutes = _minutes_until(reset_time)
546
+ if minutes is None:
547
+ return AgyQuotaWindow(remaining, None, None)
548
+ minutes = max(0, minutes)
549
+ return AgyQuotaWindow(remaining, _format_resets_in(minutes), minutes)
550
+
551
+
552
+ def _remaining_fraction(bucket: dict[str, object]) -> float | None:
553
+ """Read remainingFraction across its three shapes (flat, nested, or case-tagged)."""
554
+ for key in ("remainingFraction", "remaining_fraction"):
555
+ value = _coerce_float(bucket.get(key))
556
+ if value is not None:
557
+ return value
558
+ remaining = bucket.get("remaining")
559
+ if not isinstance(remaining, dict):
560
+ return None
561
+ for key in ("remainingFraction", "remaining_fraction"):
562
+ value = _coerce_float(remaining.get(key))
563
+ if value is not None:
564
+ return value
565
+ if _coerce_str(remaining.get("case")) == "remainingFraction":
566
+ return _coerce_float(remaining.get("value"))
567
+ return None
568
+
569
+
570
+ def _bucket_reset_time(bucket: dict[str, object]) -> str | None:
571
+ for key in ("resetTime", "reset_time", "resetAt", "reset_at"):
572
+ value = _coerce_str(bucket.get(key))
573
+ if value:
574
+ return value
575
+ return None
576
+
577
+
578
+ def _seconds_until(expiry: str) -> float:
579
+ parsed = _parse_timestamp(expiry)
580
+ if parsed is None:
581
+ return -1.0
582
+ return (parsed - datetime.now(UTC)).total_seconds()
583
+
584
+
585
+ def _minutes_until(reset_time: str) -> int | None:
586
+ parsed = _parse_timestamp(reset_time)
587
+ if parsed is None:
588
+ return None
589
+ delta_seconds = (parsed - datetime.now(UTC)).total_seconds()
590
+ return int(delta_seconds // 60)
591
+
592
+
593
+ def _format_resets_in(minutes: int) -> str:
594
+ if minutes >= 60:
595
+ return f"{minutes // 60}h {minutes % 60}m"
596
+ return f"{minutes}m"
597
+
598
+
599
+ def _coerce_str(value: object) -> str | None:
600
+ if isinstance(value, bool):
601
+ return None
602
+ if isinstance(value, str):
603
+ text = value.strip()
604
+ return text or None
605
+ if isinstance(value, (int, float)):
606
+ return str(value)
607
+ return None
608
+
609
+
610
+ def _coerce_float(value: object) -> float | None:
611
+ if isinstance(value, bool):
612
+ return None
613
+ if isinstance(value, (int, float)):
614
+ return float(value)
615
+ if isinstance(value, str):
616
+ try:
617
+ return float(value.strip())
618
+ except ValueError:
619
+ return None
620
+ return None
621
+
622
+
623
+ def _is_fresh(result: AgyQuotaResult, max_age_minutes: float) -> bool:
624
+ if max_age_minutes < 0:
625
+ return False
626
+ fetched_at = _parse_timestamp(result.fetched_at)
627
+ if fetched_at is None:
628
+ return False
629
+ return datetime.now(UTC) - fetched_at <= timedelta(minutes=max_age_minutes)
630
+
631
+
632
+ def _parse_timestamp(value: str) -> datetime | None:
633
+ try:
634
+ timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
635
+ except ValueError:
636
+ return None
637
+ if timestamp.tzinfo is None:
638
+ return None
639
+ return timestamp.astimezone(UTC)
640
+
641
+
642
+ def _read_cache() -> AgyQuotaResult | None:
643
+ try:
644
+ with CACHE_PATH.open(encoding="utf-8") as cache_file:
645
+ payload: object = json.load(cache_file)
646
+ except (OSError, json.JSONDecodeError):
647
+ return None
648
+ return _result_from_payload(payload)
649
+
650
+
651
+ def _write_cache(result: AgyQuotaResult) -> None:
652
+ temporary_path: str | None = None
653
+ try:
654
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
655
+ fd, temporary_path = tempfile.mkstemp(dir=CACHE_PATH.parent, suffix=".tmp")
656
+ with os.fdopen(fd, "w", encoding="utf-8") as cache_file:
657
+ json.dump(_result_to_payload(result), cache_file, ensure_ascii=False)
658
+ os.replace(temporary_path, CACHE_PATH)
659
+ temporary_path = None
660
+ except OSError:
661
+ return
662
+ finally:
663
+ if temporary_path is not None:
664
+ with suppress(OSError):
665
+ os.unlink(temporary_path)
666
+
667
+
668
+ def _result_to_payload(result: AgyQuotaResult) -> dict[str, object]:
669
+ return {
670
+ "fetched_at": result.fetched_at,
671
+ "groups": [
672
+ {
673
+ "name": group.name,
674
+ "models": group.models,
675
+ "weekly": _window_to_payload(group.weekly),
676
+ "five_hour": _window_to_payload(group.five_hour),
677
+ }
678
+ for group in result.groups
679
+ ],
680
+ }
681
+
682
+
683
+ def _window_to_payload(window: AgyQuotaWindow) -> dict[str, float | int | str | None]:
684
+ return {
685
+ "remaining_percent": window.remaining_percent,
686
+ "resets_in": window.resets_in,
687
+ "resets_in_minutes": window.resets_in_minutes,
688
+ }
689
+
690
+
691
+ def _result_from_payload(payload: object) -> AgyQuotaResult | None:
692
+ if not isinstance(payload, dict):
693
+ return None
694
+ fetched_at = payload.get("fetched_at")
695
+ raw_groups = payload.get("groups")
696
+ if not isinstance(fetched_at, str) or _parse_timestamp(fetched_at) is None:
697
+ return None
698
+ if not isinstance(raw_groups, list) or not raw_groups:
699
+ return None
700
+ groups: list[AgyQuotaGroup] = []
701
+ for raw_group in raw_groups:
702
+ group = _group_from_payload(raw_group)
703
+ if group is None:
704
+ return None
705
+ groups.append(group)
706
+ return AgyQuotaResult(groups=groups, fetched_at=fetched_at)
707
+
708
+
709
+ def _group_from_payload(payload: object) -> AgyQuotaGroup | None:
710
+ if not isinstance(payload, dict):
711
+ return None
712
+ name = payload.get("name")
713
+ models = payload.get("models")
714
+ weekly = _window_from_payload(payload.get("weekly"))
715
+ five_hour = _window_from_payload(payload.get("five_hour"))
716
+ if (
717
+ not isinstance(name, str)
718
+ or not name
719
+ or not isinstance(models, list)
720
+ or not all(isinstance(model, str) and model for model in models)
721
+ or weekly is None
722
+ or five_hour is None
723
+ ):
724
+ return None
725
+ return AgyQuotaGroup(name=name, models=models, weekly=weekly, five_hour=five_hour)
726
+
727
+
728
+ def _window_from_payload(payload: object) -> AgyQuotaWindow | None:
729
+ if not isinstance(payload, dict):
730
+ return None
731
+ remaining_percent = payload.get("remaining_percent")
732
+ resets_in = payload.get("resets_in")
733
+ resets_in_minutes = payload.get("resets_in_minutes")
734
+ if (
735
+ not isinstance(remaining_percent, (int, float))
736
+ or isinstance(remaining_percent, bool)
737
+ or not 0 <= float(remaining_percent) <= 100
738
+ or (resets_in is not None and not isinstance(resets_in, str))
739
+ or (resets_in_minutes is not None and not isinstance(resets_in_minutes, int))
740
+ ):
741
+ return None
742
+ if (resets_in is None) != (resets_in_minutes is None):
743
+ return None
744
+ return AgyQuotaWindow(
745
+ remaining_percent=float(remaining_percent),
746
+ resets_in=resets_in,
747
+ resets_in_minutes=resets_in_minutes,
748
+ )