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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- 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
|
+
)
|