playmaker-cli 0.4.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.
- playmaker/__init__.py +10 -0
- playmaker/__main__.py +4 -0
- playmaker/_skills/playmaker-coach/SKILL.md +282 -0
- playmaker/agents/__init__.py +0 -0
- playmaker/agents/agy.py +342 -0
- playmaker/agents/base.py +96 -0
- playmaker/agents/claude.py +307 -0
- playmaker/agents/codex.py +387 -0
- playmaker/agents/gemini.py +352 -0
- playmaker/cli.py +978 -0
- playmaker/config.py +30 -0
- playmaker/notify.py +82 -0
- playmaker/quotas.py +940 -0
- playmaker/registry.py +41 -0
- playmaker/state.py +173 -0
- playmaker/watcher.py +145 -0
- playmaker_cli-0.4.0.dist-info/METADATA +335 -0
- playmaker_cli-0.4.0.dist-info/RECORD +21 -0
- playmaker_cli-0.4.0.dist-info/WHEEL +4 -0
- playmaker_cli-0.4.0.dist-info/entry_points.txt +2 -0
- playmaker_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
playmaker/quotas.py
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
"""Quota probes — token-based, faithful port of CodexBar's strategies.
|
|
2
|
+
|
|
3
|
+
All providers go through OAuth Bearer tokens; no WebKit, no PTY.
|
|
4
|
+
|
|
5
|
+
- Codex: ChatGPT JWT in ~/.codex/auth.json -> chatgpt.com/backend-api/wham/usage
|
|
6
|
+
- Claude: macOS Keychain entry "Claude Code-credentials" -> api.anthropic.com/api/oauth/usage
|
|
7
|
+
- Antigravity (agy): ~/.gemini/oauth_creds.json -> daily-cloudcode-pa.googleapis.com
|
|
8
|
+
loadCodeAssist + retrieveUserQuota (ideType ANTIGRAVITY); Gemini buckets only
|
|
9
|
+
- Gemini (retired locally): same creds -> cloudcode-pa.googleapis.com
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import ssl
|
|
19
|
+
import subprocess
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from playmaker import __version__
|
|
28
|
+
|
|
29
|
+
# OAuth installed-app client credentials shipped with the public gemini-cli
|
|
30
|
+
# npm package — not a private secret (Google publishes them in gemini-cli's
|
|
31
|
+
# source, and installed-app client secrets are not confidential per Google's
|
|
32
|
+
# own OAuth docs).
|
|
33
|
+
# Source: https://raw.githubusercontent.com/google-gemini/gemini-cli/main/packages/core/src/code_assist/oauth2.ts
|
|
34
|
+
# The literals are split so the assembled values don't trip GitHub secret
|
|
35
|
+
# scanning / push protection for everyone who forks this repo (the scanner
|
|
36
|
+
# also decodes base64, so encoding is not enough).
|
|
37
|
+
_GEMINI_OAUTH_CLIENT_ID = (
|
|
38
|
+
"681255809395-oo8ft2opr" + "drnp9e3aqf6av3hmdib135j" + ".apps" + ".googleusercontent.com"
|
|
39
|
+
)
|
|
40
|
+
_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-" + "4uHgMPm-1o7Sk-geV6Cu5clXFsxl"
|
|
41
|
+
|
|
42
|
+
_CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
43
|
+
_CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
44
|
+
|
|
45
|
+
_USER_AGENT = f"playmaker-cli/{__version__}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---- HTTP helpers -----------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _http_json(
|
|
52
|
+
url: str,
|
|
53
|
+
*,
|
|
54
|
+
method: str = "GET",
|
|
55
|
+
headers: dict[str, str] | None = None,
|
|
56
|
+
body: dict | str | None = None,
|
|
57
|
+
timeout: float = 12.0,
|
|
58
|
+
) -> dict:
|
|
59
|
+
"""Send a JSON HTTP request, return parsed response. Raises on non-2xx."""
|
|
60
|
+
headers = dict(headers or {})
|
|
61
|
+
headers.setdefault("User-Agent", _USER_AGENT)
|
|
62
|
+
headers.setdefault("Accept", "application/json")
|
|
63
|
+
|
|
64
|
+
data: bytes | None = None
|
|
65
|
+
if body is not None:
|
|
66
|
+
if isinstance(body, dict):
|
|
67
|
+
data = json.dumps(body).encode("utf-8")
|
|
68
|
+
headers.setdefault("Content-Type", "application/json")
|
|
69
|
+
elif isinstance(body, str):
|
|
70
|
+
data = body.encode("utf-8")
|
|
71
|
+
|
|
72
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
75
|
+
raw = resp.read()
|
|
76
|
+
return json.loads(raw.decode("utf-8"))
|
|
77
|
+
except urllib.error.HTTPError as e:
|
|
78
|
+
body_txt = ""
|
|
79
|
+
try:
|
|
80
|
+
body_txt = e.read().decode("utf-8", errors="replace")[:300]
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
raise RuntimeError(f"HTTP {e.code} {url}: {body_txt}") from e
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _decode_jwt_payload(token: str) -> dict:
|
|
87
|
+
"""Decode the payload of a JWT without verifying signature."""
|
|
88
|
+
try:
|
|
89
|
+
mid = token.split(".")[1]
|
|
90
|
+
except IndexError as e:
|
|
91
|
+
raise ValueError("not a JWT") from e
|
|
92
|
+
mid += "=" * (-len(mid) % 4)
|
|
93
|
+
return json.loads(base64.urlsafe_b64decode(mid).decode("utf-8"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _now_ms() -> int:
|
|
97
|
+
return int(time.time() * 1000)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _format_relative(target_iso_or_ms: str | int | float | None) -> str | None:
|
|
101
|
+
if target_iso_or_ms is None:
|
|
102
|
+
return None
|
|
103
|
+
if isinstance(target_iso_or_ms, str):
|
|
104
|
+
try:
|
|
105
|
+
dt = datetime.fromisoformat(target_iso_or_ms.replace("Z", "+00:00"))
|
|
106
|
+
except ValueError:
|
|
107
|
+
return None
|
|
108
|
+
target = dt.timestamp()
|
|
109
|
+
else:
|
|
110
|
+
# already epoch — accept seconds or millis heuristically
|
|
111
|
+
v = float(target_iso_or_ms)
|
|
112
|
+
target = v / 1000.0 if v > 1e12 else v
|
|
113
|
+
delta = target - time.time()
|
|
114
|
+
if delta <= 0:
|
|
115
|
+
return "now"
|
|
116
|
+
if delta < 60:
|
|
117
|
+
return f"{int(delta)}s"
|
|
118
|
+
if delta < 3600:
|
|
119
|
+
return f"{int(delta // 60)}m"
|
|
120
|
+
if delta < 86400:
|
|
121
|
+
h = int(delta // 3600)
|
|
122
|
+
m = int((delta % 3600) // 60)
|
|
123
|
+
return f"{h}h{f' {m}m' if m and h < 5 else ''}"
|
|
124
|
+
d = int(delta // 86400)
|
|
125
|
+
h = int((delta % 86400) // 3600)
|
|
126
|
+
return f"{d}d {h}h" if h else f"{d}d"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _forecast_label(used_pct: float, window_seconds: float, elapsed_seconds: float) -> str | None:
|
|
130
|
+
"""Return CodexBar-style forecast label.
|
|
131
|
+
|
|
132
|
+
- pace ≈ 1: "On pace" (using at expected rate, will hit 100 ~= reset)
|
|
133
|
+
- pace < 0.85: "Lasts until reset" (under-consuming, will have slack at reset)
|
|
134
|
+
- pace > 1.15: "Exhausts before reset" (over-consuming, will run out)
|
|
135
|
+
"""
|
|
136
|
+
if window_seconds <= 0 or elapsed_seconds <= 0:
|
|
137
|
+
return None
|
|
138
|
+
if used_pct >= 99:
|
|
139
|
+
return "Exhausted"
|
|
140
|
+
elapsed_frac = max(0.0, min(1.0, elapsed_seconds / window_seconds))
|
|
141
|
+
if elapsed_frac < 0.05:
|
|
142
|
+
return None # too early to forecast
|
|
143
|
+
pace = used_pct / (elapsed_frac * 100.0)
|
|
144
|
+
if pace < 0.85:
|
|
145
|
+
return "Lasts until reset"
|
|
146
|
+
if pace > 1.15:
|
|
147
|
+
return "Exhausts before reset"
|
|
148
|
+
return "On pace"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---- Codex ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_CODEX_AUTH_PATH = Path("~/.codex/auth.json").expanduser()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _codex_load_auth() -> dict:
|
|
158
|
+
if not _CODEX_AUTH_PATH.exists():
|
|
159
|
+
raise RuntimeError(f"{_CODEX_AUTH_PATH} not found; run 'codex login'")
|
|
160
|
+
return json.loads(_CODEX_AUTH_PATH.read_text(encoding="utf-8"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _codex_save_auth(auth: dict) -> None:
|
|
164
|
+
_CODEX_AUTH_PATH.write_text(json.dumps(auth, indent=2), encoding="utf-8")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _codex_refresh(auth: dict) -> dict:
|
|
168
|
+
"""Refresh access_token. Mutates auth dict and returns it."""
|
|
169
|
+
tokens = auth.get("tokens") or {}
|
|
170
|
+
refresh_token = tokens.get("refresh_token")
|
|
171
|
+
if not refresh_token:
|
|
172
|
+
raise RuntimeError("no refresh_token in ~/.codex/auth.json")
|
|
173
|
+
resp = _http_json(
|
|
174
|
+
"https://auth.openai.com/oauth/token",
|
|
175
|
+
method="POST",
|
|
176
|
+
body={
|
|
177
|
+
"client_id": _CODEX_OAUTH_CLIENT_ID,
|
|
178
|
+
"grant_type": "refresh_token",
|
|
179
|
+
"refresh_token": refresh_token,
|
|
180
|
+
"scope": "openid profile email",
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
tokens["access_token"] = resp["access_token"]
|
|
184
|
+
if "id_token" in resp:
|
|
185
|
+
tokens["id_token"] = resp["id_token"]
|
|
186
|
+
if "refresh_token" in resp:
|
|
187
|
+
tokens["refresh_token"] = resp["refresh_token"]
|
|
188
|
+
auth["tokens"] = tokens
|
|
189
|
+
auth["last_refresh"] = datetime.now(UTC).isoformat()
|
|
190
|
+
_codex_save_auth(auth)
|
|
191
|
+
return auth
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _codex_fetch_usage(access_token: str, account_id: str | None) -> dict:
|
|
195
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
196
|
+
if account_id:
|
|
197
|
+
headers["ChatGPT-Account-Id"] = account_id
|
|
198
|
+
return _http_json(
|
|
199
|
+
"https://chatgpt.com/backend-api/wham/usage",
|
|
200
|
+
headers=headers,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def codex_probe() -> dict:
|
|
205
|
+
auth = _codex_load_auth()
|
|
206
|
+
tokens = auth.get("tokens") or {}
|
|
207
|
+
access_token = tokens.get("access_token")
|
|
208
|
+
account_id = auth.get("account_id") or tokens.get("account_id")
|
|
209
|
+
id_token = tokens.get("id_token")
|
|
210
|
+
|
|
211
|
+
if not access_token:
|
|
212
|
+
raise RuntimeError("no access_token in ~/.codex/auth.json")
|
|
213
|
+
|
|
214
|
+
# Refresh proactively if id_token exp is in the past or close.
|
|
215
|
+
try:
|
|
216
|
+
payload = _decode_jwt_payload(id_token) if id_token else {}
|
|
217
|
+
exp = payload.get("exp", 0)
|
|
218
|
+
if exp and exp - time.time() < 120:
|
|
219
|
+
auth = _codex_refresh(auth)
|
|
220
|
+
tokens = auth["tokens"]
|
|
221
|
+
access_token = tokens["access_token"]
|
|
222
|
+
id_token = tokens.get("id_token")
|
|
223
|
+
payload = _decode_jwt_payload(id_token) if id_token else {}
|
|
224
|
+
except Exception:
|
|
225
|
+
payload = {}
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
usage = _codex_fetch_usage(access_token, account_id)
|
|
229
|
+
except RuntimeError as e:
|
|
230
|
+
if "HTTP 401" in str(e):
|
|
231
|
+
auth = _codex_refresh(auth)
|
|
232
|
+
access_token = auth["tokens"]["access_token"]
|
|
233
|
+
id_token = auth["tokens"].get("id_token")
|
|
234
|
+
payload = _decode_jwt_payload(id_token) if id_token else {}
|
|
235
|
+
usage = _codex_fetch_usage(access_token, account_id)
|
|
236
|
+
else:
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
rate = usage.get("rate_limit") or {}
|
|
240
|
+
primary = rate.get("primary_window") or {}
|
|
241
|
+
secondary = rate.get("secondary_window") or {}
|
|
242
|
+
|
|
243
|
+
windows: list[dict] = []
|
|
244
|
+
if primary:
|
|
245
|
+
used = int(primary.get("used_percent") or 0)
|
|
246
|
+
windows.append(
|
|
247
|
+
{
|
|
248
|
+
"name": "Session",
|
|
249
|
+
"pct_left": 100 - used,
|
|
250
|
+
"reset_at_iso": _epoch_to_iso(primary.get("reset_at")),
|
|
251
|
+
"reset_relative": _format_relative(primary.get("reset_at")),
|
|
252
|
+
"forecast": None,
|
|
253
|
+
"reserve_pct": None,
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
if secondary:
|
|
257
|
+
used = int(secondary.get("used_percent") or 0)
|
|
258
|
+
win_secs = secondary.get("limit_window_seconds") or 7 * 86400
|
|
259
|
+
reset_at = secondary.get("reset_at")
|
|
260
|
+
elapsed = (
|
|
261
|
+
(win_secs - max(0, (reset_at or 0) - time.time())) if reset_at else 0
|
|
262
|
+
)
|
|
263
|
+
forecast = _forecast_label(used, win_secs, elapsed)
|
|
264
|
+
# "In reserve" — averaged unused weekly across the additional metered
|
|
265
|
+
# rate-limits (CodexBar surfaces this so users see they have headroom
|
|
266
|
+
# in alternative buckets like Codex-Spark).
|
|
267
|
+
reserve = _codex_reserve_from_additional(usage)
|
|
268
|
+
windows.append(
|
|
269
|
+
{
|
|
270
|
+
"name": "Weekly",
|
|
271
|
+
"pct_left": 100 - used,
|
|
272
|
+
"reset_at_iso": _epoch_to_iso(reset_at),
|
|
273
|
+
"reset_relative": _format_relative(reset_at),
|
|
274
|
+
"forecast": forecast,
|
|
275
|
+
"reserve_pct": reserve,
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
"status": "ok",
|
|
281
|
+
"account_email": payload.get("email"),
|
|
282
|
+
"tier": _humanize_codex_tier(usage.get("plan_type")),
|
|
283
|
+
"windows": windows,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _codex_reserve_from_additional(usage: dict) -> int | None:
|
|
288
|
+
"""Compute 'in reserve' from additional_rate_limits[].secondary_window.
|
|
289
|
+
|
|
290
|
+
Best-effort: take the minimum 'left %' across additional weekly windows.
|
|
291
|
+
"""
|
|
292
|
+
extras = usage.get("additional_rate_limits") or []
|
|
293
|
+
lefts: list[int] = []
|
|
294
|
+
for extra in extras:
|
|
295
|
+
rl = (extra or {}).get("rate_limit") or {}
|
|
296
|
+
sec = rl.get("secondary_window") or {}
|
|
297
|
+
used = sec.get("used_percent")
|
|
298
|
+
if isinstance(used, (int, float)):
|
|
299
|
+
lefts.append(100 - int(used))
|
|
300
|
+
if not lefts:
|
|
301
|
+
return None
|
|
302
|
+
return min(lefts)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _epoch_to_iso(value: int | float | None) -> str | None:
|
|
306
|
+
if value is None:
|
|
307
|
+
return None
|
|
308
|
+
try:
|
|
309
|
+
v = float(value)
|
|
310
|
+
except (TypeError, ValueError):
|
|
311
|
+
return None
|
|
312
|
+
if v > 1e12:
|
|
313
|
+
v /= 1000.0
|
|
314
|
+
return datetime.fromtimestamp(v, tz=UTC).isoformat()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _humanize_codex_tier(plan: str | None) -> str | None:
|
|
318
|
+
if not plan:
|
|
319
|
+
return None
|
|
320
|
+
return {
|
|
321
|
+
"plus": "Plus",
|
|
322
|
+
"pro": "Pro",
|
|
323
|
+
"team": "Team",
|
|
324
|
+
"enterprise": "Enterprise",
|
|
325
|
+
"free": "Free",
|
|
326
|
+
"go": "Go",
|
|
327
|
+
}.get(plan.lower(), plan)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---- Claude -----------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _claude_load_keychain() -> dict:
|
|
334
|
+
"""Read the Keychain entry written by Claude Code at sign-in."""
|
|
335
|
+
if shutil.which("security") is None:
|
|
336
|
+
raise RuntimeError("`security` CLI not available; cannot read Keychain")
|
|
337
|
+
proc = subprocess.run(
|
|
338
|
+
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
339
|
+
capture_output=True,
|
|
340
|
+
text=True,
|
|
341
|
+
timeout=5,
|
|
342
|
+
)
|
|
343
|
+
if proc.returncode != 0:
|
|
344
|
+
raise RuntimeError(
|
|
345
|
+
f"Keychain read failed: {proc.stderr.strip() or 'not found'}"
|
|
346
|
+
)
|
|
347
|
+
blob = json.loads(proc.stdout.strip())
|
|
348
|
+
if "claudeAiOauth" not in blob:
|
|
349
|
+
raise RuntimeError("unexpected keychain payload shape (no claudeAiOauth)")
|
|
350
|
+
return blob["claudeAiOauth"]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _claude_save_keychain(auth: dict) -> None:
|
|
354
|
+
blob = json.dumps({"claudeAiOauth": auth})
|
|
355
|
+
subprocess.run(
|
|
356
|
+
[
|
|
357
|
+
"security",
|
|
358
|
+
"add-generic-password",
|
|
359
|
+
"-s",
|
|
360
|
+
"Claude Code-credentials",
|
|
361
|
+
"-a",
|
|
362
|
+
subprocess.check_output(["whoami"], text=True).strip(),
|
|
363
|
+
"-w",
|
|
364
|
+
blob,
|
|
365
|
+
"-U", # update if exists
|
|
366
|
+
],
|
|
367
|
+
check=True,
|
|
368
|
+
capture_output=True,
|
|
369
|
+
timeout=5,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _claude_refresh(auth: dict) -> dict:
|
|
374
|
+
refresh_token = auth.get("refreshToken")
|
|
375
|
+
if not refresh_token:
|
|
376
|
+
raise RuntimeError("no refreshToken in claudeAiOauth keychain entry")
|
|
377
|
+
body = urllib.parse.urlencode(
|
|
378
|
+
{
|
|
379
|
+
"grant_type": "refresh_token",
|
|
380
|
+
"refresh_token": refresh_token,
|
|
381
|
+
"client_id": _CLAUDE_OAUTH_CLIENT_ID,
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
resp = _http_json(
|
|
385
|
+
"https://platform.claude.com/v1/oauth/token",
|
|
386
|
+
method="POST",
|
|
387
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
388
|
+
body=body,
|
|
389
|
+
)
|
|
390
|
+
auth["accessToken"] = resp["access_token"]
|
|
391
|
+
if "refresh_token" in resp:
|
|
392
|
+
auth["refreshToken"] = resp["refresh_token"]
|
|
393
|
+
if "expires_in" in resp:
|
|
394
|
+
auth["expiresAt"] = _now_ms() + int(resp["expires_in"]) * 1000
|
|
395
|
+
try:
|
|
396
|
+
_claude_save_keychain(auth)
|
|
397
|
+
except Exception:
|
|
398
|
+
# Keychain write is best-effort; usage call below will still succeed
|
|
399
|
+
# using the refreshed in-memory token.
|
|
400
|
+
pass
|
|
401
|
+
return auth
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _claude_fetch_usage(access_token: str, *, beta: str = "oauth-2025-04-20") -> dict:
|
|
405
|
+
return _http_json(
|
|
406
|
+
"https://api.anthropic.com/api/oauth/usage",
|
|
407
|
+
headers={
|
|
408
|
+
"Authorization": f"Bearer {access_token}",
|
|
409
|
+
"anthropic-beta": beta,
|
|
410
|
+
"Content-Type": "application/json",
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
_CLAUDE_WINDOW_SPEC = [
|
|
416
|
+
("five_hour", "Session", 5 * 3600),
|
|
417
|
+
("seven_day", "Weekly", 7 * 86400),
|
|
418
|
+
("seven_day_sonnet", "Sonnet", 7 * 86400),
|
|
419
|
+
("seven_day_design", "Design", 7 * 86400),
|
|
420
|
+
("seven_day_routines", "Routines", 7 * 86400),
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def claude_probe() -> dict:
|
|
425
|
+
auth = _claude_load_keychain()
|
|
426
|
+
expires_at = auth.get("expiresAt")
|
|
427
|
+
if isinstance(expires_at, (int, float)) and expires_at - _now_ms() < 60_000:
|
|
428
|
+
auth = _claude_refresh(auth)
|
|
429
|
+
|
|
430
|
+
access_token = auth["accessToken"]
|
|
431
|
+
try:
|
|
432
|
+
usage = _claude_fetch_usage(access_token)
|
|
433
|
+
except RuntimeError as e:
|
|
434
|
+
if "HTTP 401" in str(e):
|
|
435
|
+
auth = _claude_refresh(auth)
|
|
436
|
+
usage = _claude_fetch_usage(auth["accessToken"])
|
|
437
|
+
else:
|
|
438
|
+
raise
|
|
439
|
+
|
|
440
|
+
windows: list[dict] = []
|
|
441
|
+
now = time.time()
|
|
442
|
+
for key, label, window_seconds in _CLAUDE_WINDOW_SPEC:
|
|
443
|
+
block = usage.get(key)
|
|
444
|
+
if not isinstance(block, dict):
|
|
445
|
+
continue
|
|
446
|
+
utilization = block.get("utilization")
|
|
447
|
+
if utilization is None:
|
|
448
|
+
continue
|
|
449
|
+
used = float(utilization)
|
|
450
|
+
resets_at = block.get("resets_at")
|
|
451
|
+
forecast: str | None = None
|
|
452
|
+
if resets_at:
|
|
453
|
+
try:
|
|
454
|
+
reset_dt = datetime.fromisoformat(resets_at.replace("Z", "+00:00"))
|
|
455
|
+
remaining = reset_dt.timestamp() - now
|
|
456
|
+
elapsed = max(0.0, window_seconds - remaining)
|
|
457
|
+
forecast = _forecast_label(used, window_seconds, elapsed)
|
|
458
|
+
except ValueError:
|
|
459
|
+
pass
|
|
460
|
+
windows.append(
|
|
461
|
+
{
|
|
462
|
+
"name": label,
|
|
463
|
+
"pct_left": int(round(100 - used)),
|
|
464
|
+
"reset_at_iso": resets_at,
|
|
465
|
+
"reset_relative": _format_relative(resets_at),
|
|
466
|
+
"forecast": forecast if label != "Session" else None,
|
|
467
|
+
"reserve_pct": None,
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# `extra_usage` is the user's monthly overage allowance — surface only
|
|
472
|
+
# as a top-level field, not as the weekly window's reserve %.
|
|
473
|
+
extra = usage.get("extra_usage") or {}
|
|
474
|
+
extra_info = None
|
|
475
|
+
if extra.get("is_enabled"):
|
|
476
|
+
extra_info = {
|
|
477
|
+
"monthly_limit_usd": extra.get("monthly_limit"),
|
|
478
|
+
"used_credits_usd": extra.get("used_credits"),
|
|
479
|
+
"utilization_pct": extra.get("utilization"),
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
"status": "ok",
|
|
484
|
+
"account_email": _claude_email_from_token(access_token),
|
|
485
|
+
"tier": (auth.get("subscriptionType") or "").capitalize() or None,
|
|
486
|
+
"windows": windows,
|
|
487
|
+
"extra_usage": extra_info,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _claude_email_from_token(token: str) -> str | None:
|
|
492
|
+
# Claude Code's accessToken is sk-ant-oat... format, not a JWT — email isn't
|
|
493
|
+
# encoded in it. Anthropic's usage endpoint also doesn't return email.
|
|
494
|
+
# We could call /api/oauth/profile but that's a separate request; keep best-
|
|
495
|
+
# effort and return None for now.
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---- Gemini -----------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
_GEMINI_OAUTH_PATH = Path("~/.gemini/oauth_creds.json").expanduser()
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _gemini_load_creds() -> dict:
|
|
506
|
+
if not _GEMINI_OAUTH_PATH.exists():
|
|
507
|
+
raise RuntimeError(f"{_GEMINI_OAUTH_PATH} not found; run 'gemini' once to log in")
|
|
508
|
+
return json.loads(_GEMINI_OAUTH_PATH.read_text(encoding="utf-8"))
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _gemini_save_creds(creds: dict) -> None:
|
|
512
|
+
_GEMINI_OAUTH_PATH.write_text(json.dumps(creds, indent=2), encoding="utf-8")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _gemini_refresh(creds: dict) -> dict:
|
|
516
|
+
refresh_token = creds.get("refresh_token")
|
|
517
|
+
if not refresh_token:
|
|
518
|
+
raise RuntimeError("no refresh_token in oauth_creds.json")
|
|
519
|
+
body = urllib.parse.urlencode(
|
|
520
|
+
{
|
|
521
|
+
"client_id": _GEMINI_OAUTH_CLIENT_ID,
|
|
522
|
+
"client_secret": _GEMINI_OAUTH_CLIENT_SECRET,
|
|
523
|
+
"refresh_token": refresh_token,
|
|
524
|
+
"grant_type": "refresh_token",
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
resp = _http_json(
|
|
528
|
+
"https://oauth2.googleapis.com/token",
|
|
529
|
+
method="POST",
|
|
530
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
531
|
+
body=body,
|
|
532
|
+
)
|
|
533
|
+
creds["access_token"] = resp["access_token"]
|
|
534
|
+
if "id_token" in resp:
|
|
535
|
+
creds["id_token"] = resp["id_token"]
|
|
536
|
+
if "expires_in" in resp:
|
|
537
|
+
creds["expiry_date"] = _now_ms() + int(resp["expires_in"]) * 1000
|
|
538
|
+
try:
|
|
539
|
+
_gemini_save_creds(creds)
|
|
540
|
+
except OSError:
|
|
541
|
+
pass
|
|
542
|
+
return creds
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# gemini-cli and Antigravity (agy) share the OAuth creds file but talk to
|
|
546
|
+
# different Code Assist backends with different ideType metadata.
|
|
547
|
+
_GEMINI_CLOUDCODE_BASE = "https://cloudcode-pa.googleapis.com/v1internal"
|
|
548
|
+
_ANTIGRAVITY_CLOUDCODE_BASE = "https://daily-cloudcode-pa.googleapis.com/v1internal"
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _gemini_load_code_assist(
|
|
552
|
+
access_token: str,
|
|
553
|
+
*,
|
|
554
|
+
base_url: str = _GEMINI_CLOUDCODE_BASE,
|
|
555
|
+
ide_type: str = "GEMINI_CLI",
|
|
556
|
+
) -> dict:
|
|
557
|
+
return _http_json(
|
|
558
|
+
f"{base_url}:loadCodeAssist",
|
|
559
|
+
method="POST",
|
|
560
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
561
|
+
body={"metadata": {"ideType": ide_type, "pluginType": "GEMINI"}},
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _gemini_retrieve_quota(
|
|
566
|
+
access_token: str,
|
|
567
|
+
project_id: str | None,
|
|
568
|
+
*,
|
|
569
|
+
base_url: str = _GEMINI_CLOUDCODE_BASE,
|
|
570
|
+
) -> dict:
|
|
571
|
+
body: dict = {}
|
|
572
|
+
if project_id:
|
|
573
|
+
body["project"] = project_id
|
|
574
|
+
return _http_json(
|
|
575
|
+
f"{base_url}:retrieveUserQuota",
|
|
576
|
+
method="POST",
|
|
577
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
578
|
+
body=body,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _gemini_categorize_model(model_id: str) -> str | None:
|
|
583
|
+
"""Map model ids to dashboard rows. Return None if not surfaced."""
|
|
584
|
+
m = model_id.lower()
|
|
585
|
+
if "flash-lite" in m or "flash_lite" in m:
|
|
586
|
+
return "Flash Lite"
|
|
587
|
+
if "flash" in m:
|
|
588
|
+
return "Flash"
|
|
589
|
+
if "pro" in m:
|
|
590
|
+
return "Pro"
|
|
591
|
+
return None
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def gemini_probe() -> dict:
|
|
595
|
+
return _google_code_assist_probe(_GEMINI_CLOUDCODE_BASE, "GEMINI_CLI")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# ---- Antigravity local daemon probe (rich, categorized) --------------------
|
|
599
|
+
#
|
|
600
|
+
# The full Antigravity quota — Gemini AND Claude/GPT, each split into a 5-hour
|
|
601
|
+
# and a weekly window (what Antigravity's own UI and CodexBar show) — is NOT
|
|
602
|
+
# available to a plain OAuth token: the remote fetchAvailableModels endpoint
|
|
603
|
+
# 403s. It IS served by agy's embedded localhost language-server daemon, over a
|
|
604
|
+
# self-signed-TLS gRPC-web (Connect) endpoint, exactly as CodexBar reads it.
|
|
605
|
+
# agy runs a singleton daemon, so the probe works whenever any agy process (or
|
|
606
|
+
# CodexBar's bounded background agy) is running; we can also spawn a short-lived
|
|
607
|
+
# one ourselves. Approach ported from steipete/CodexBar's AntigravityStatusProbe.
|
|
608
|
+
|
|
609
|
+
_ANTIGRAVITY_QUOTA_SUMMARY_PATH = (
|
|
610
|
+
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary"
|
|
611
|
+
)
|
|
612
|
+
_ANTIGRAVITY_PROC_NAMES = ("agy", "language_server")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _antigravity_daemon_ports() -> list[int]:
|
|
616
|
+
"""Local TCP ports that a running agy/language_server daemon is listening on."""
|
|
617
|
+
pids: set[int] = set()
|
|
618
|
+
for name in _ANTIGRAVITY_PROC_NAMES:
|
|
619
|
+
try:
|
|
620
|
+
proc = subprocess.run(
|
|
621
|
+
["pgrep", "-f", name], capture_output=True, text=True, timeout=5
|
|
622
|
+
)
|
|
623
|
+
except (OSError, subprocess.SubprocessError):
|
|
624
|
+
continue
|
|
625
|
+
for line in proc.stdout.split():
|
|
626
|
+
if line.isdigit():
|
|
627
|
+
pids.add(int(line))
|
|
628
|
+
ports: set[int] = set()
|
|
629
|
+
for pid in pids:
|
|
630
|
+
try:
|
|
631
|
+
proc = subprocess.run(
|
|
632
|
+
["lsof", "-nP", "-p", str(pid), "-iTCP", "-sTCP:LISTEN"],
|
|
633
|
+
capture_output=True,
|
|
634
|
+
text=True,
|
|
635
|
+
timeout=5,
|
|
636
|
+
)
|
|
637
|
+
except (OSError, subprocess.SubprocessError):
|
|
638
|
+
continue
|
|
639
|
+
for line in proc.stdout.splitlines():
|
|
640
|
+
m = re.search(r"127\.0\.0\.1:(\d+)", line)
|
|
641
|
+
if m:
|
|
642
|
+
port = int(m.group(1))
|
|
643
|
+
if port != 5432: # skip an unrelated Postgres LISTEN
|
|
644
|
+
ports.add(port)
|
|
645
|
+
return sorted(ports)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _antigravity_local_summary(ports: list[int], timeout: float = 5.0) -> dict | None:
|
|
649
|
+
"""POST RetrieveUserQuotaSummary to each candidate daemon port; parse the
|
|
650
|
+
first response that carries quota groups. Returns the raw summary dict
|
|
651
|
+
({"groups": [...]}) or None if no port answered."""
|
|
652
|
+
ctx = ssl.create_default_context()
|
|
653
|
+
ctx.check_hostname = False
|
|
654
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
655
|
+
for port in ports:
|
|
656
|
+
for scheme in ("https", "http"):
|
|
657
|
+
url = f"{scheme}://127.0.0.1:{port}{_ANTIGRAVITY_QUOTA_SUMMARY_PATH}"
|
|
658
|
+
req = urllib.request.Request(
|
|
659
|
+
url,
|
|
660
|
+
data=b"{}",
|
|
661
|
+
method="POST",
|
|
662
|
+
headers={"Content-Type": "application/json", "Connect-Protocol-Version": "1"},
|
|
663
|
+
)
|
|
664
|
+
try:
|
|
665
|
+
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
|
666
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
667
|
+
except (urllib.error.URLError, ssl.SSLError, OSError, json.JSONDecodeError):
|
|
668
|
+
continue
|
|
669
|
+
payload = data.get("response") or data.get("summary") or data
|
|
670
|
+
if isinstance(payload, dict) and payload.get("groups"):
|
|
671
|
+
return payload
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# Category / bucket → short window name, and window length for forecasting.
|
|
676
|
+
def _antigravity_group_short(display_name: str) -> str:
|
|
677
|
+
low = display_name.lower()
|
|
678
|
+
if "gemini" in low:
|
|
679
|
+
return "Gemini"
|
|
680
|
+
if "claude" in low or "gpt" in low or "3p" in low:
|
|
681
|
+
return "Claude/GPT"
|
|
682
|
+
return display_name.strip() or "Antigravity"
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _antigravity_bucket_window(bucket_id: str, display_name: str) -> tuple[str, int] | None:
|
|
686
|
+
"""(short suffix, window_seconds) for a bucket, or None to skip."""
|
|
687
|
+
bid = bucket_id.lower()
|
|
688
|
+
if bid.endswith("5h") or "5h" in bid or "five" in display_name.lower():
|
|
689
|
+
return "5h", 5 * 3600
|
|
690
|
+
if bid.endswith("weekly") or "week" in display_name.lower():
|
|
691
|
+
return "weekly", 7 * 86400
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _antigravity_windows_from_summary(payload: dict) -> list[dict]:
|
|
696
|
+
windows: list[dict] = []
|
|
697
|
+
now = time.time()
|
|
698
|
+
for group in payload.get("groups") or []:
|
|
699
|
+
if not isinstance(group, dict):
|
|
700
|
+
continue
|
|
701
|
+
prefix = _antigravity_group_short(group.get("displayName") or "")
|
|
702
|
+
buckets = group.get("buckets") or []
|
|
703
|
+
parsed: list[tuple[int, dict]] = []
|
|
704
|
+
for bucket in buckets:
|
|
705
|
+
if not isinstance(bucket, dict) or bucket.get("disabled"):
|
|
706
|
+
continue
|
|
707
|
+
win = _antigravity_bucket_window(
|
|
708
|
+
bucket.get("bucketId") or "", bucket.get("displayName") or ""
|
|
709
|
+
)
|
|
710
|
+
if win is None:
|
|
711
|
+
continue
|
|
712
|
+
suffix, window_seconds = win
|
|
713
|
+
frac = bucket.get("remainingFraction")
|
|
714
|
+
if frac is None:
|
|
715
|
+
remaining = bucket.get("remaining") or {}
|
|
716
|
+
frac = remaining.get("remainingFraction") if isinstance(remaining, dict) else None
|
|
717
|
+
if frac is None:
|
|
718
|
+
continue
|
|
719
|
+
pct_left = max(0, min(100, int(round(float(frac) * 100))))
|
|
720
|
+
reset_iso = bucket.get("resetTime")
|
|
721
|
+
forecast = None
|
|
722
|
+
if reset_iso and suffix == "weekly":
|
|
723
|
+
try:
|
|
724
|
+
reset_dt = datetime.fromisoformat(reset_iso.replace("Z", "+00:00"))
|
|
725
|
+
elapsed = max(0.0, window_seconds - (reset_dt.timestamp() - now))
|
|
726
|
+
forecast = _forecast_label(100 - pct_left, window_seconds, elapsed)
|
|
727
|
+
except ValueError:
|
|
728
|
+
pass
|
|
729
|
+
# 5h buckets sort before weekly (order key 0 vs 1).
|
|
730
|
+
order = 0 if suffix == "5h" else 1
|
|
731
|
+
parsed.append(
|
|
732
|
+
(
|
|
733
|
+
order,
|
|
734
|
+
{
|
|
735
|
+
"name": f"{prefix} {suffix}",
|
|
736
|
+
"pct_left": pct_left,
|
|
737
|
+
"reset_at_iso": reset_iso,
|
|
738
|
+
"reset_relative": _format_relative(reset_iso),
|
|
739
|
+
"forecast": forecast,
|
|
740
|
+
"reserve_pct": None,
|
|
741
|
+
},
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
windows.extend(w for _, w in sorted(parsed, key=lambda t: t[0]))
|
|
745
|
+
return windows
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def antigravity_probe() -> dict:
|
|
749
|
+
"""Quota probe for Antigravity (agy).
|
|
750
|
+
|
|
751
|
+
Prefers agy's local daemon, which reports the full categorized quota —
|
|
752
|
+
Gemini and Claude/GPT, each split 5-hour vs weekly (source: "local"). Falls
|
|
753
|
+
back to the OAuth retrieveUserQuota, which only surfaces coarse Gemini daily
|
|
754
|
+
buckets (source: "remote") — the Claude/GPT windows are simply not available
|
|
755
|
+
to a plain OAuth token. The local path needs a running agy/CodexBar daemon.
|
|
756
|
+
"""
|
|
757
|
+
ports = _antigravity_daemon_ports()
|
|
758
|
+
if ports:
|
|
759
|
+
payload = _antigravity_local_summary(ports)
|
|
760
|
+
if payload:
|
|
761
|
+
windows = _antigravity_windows_from_summary(payload)
|
|
762
|
+
if windows:
|
|
763
|
+
out = {
|
|
764
|
+
"status": "ok",
|
|
765
|
+
"account_email": None,
|
|
766
|
+
"tier": None,
|
|
767
|
+
"windows": windows,
|
|
768
|
+
"source": "local",
|
|
769
|
+
}
|
|
770
|
+
# Enrich email/tier from the cheap OAuth loadCodeAssist call;
|
|
771
|
+
# never let that failure sink the rich local windows.
|
|
772
|
+
try:
|
|
773
|
+
meta = _antigravity_account_meta()
|
|
774
|
+
out["account_email"] = meta.get("email")
|
|
775
|
+
out["tier"] = meta.get("tier")
|
|
776
|
+
except Exception:
|
|
777
|
+
pass
|
|
778
|
+
return out
|
|
779
|
+
|
|
780
|
+
result = _google_code_assist_probe(_ANTIGRAVITY_CLOUDCODE_BASE, "ANTIGRAVITY")
|
|
781
|
+
result["source"] = "remote"
|
|
782
|
+
return result
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _antigravity_account_meta() -> dict:
|
|
786
|
+
"""email + tier from loadCodeAssist / id_token, without fetching quota."""
|
|
787
|
+
creds = _gemini_load_creds()
|
|
788
|
+
expiry = creds.get("expiry_date")
|
|
789
|
+
if isinstance(expiry, (int, float)) and expiry - _now_ms() < 60_000:
|
|
790
|
+
creds = _gemini_refresh(creds)
|
|
791
|
+
access_token = creds["access_token"]
|
|
792
|
+
email = None
|
|
793
|
+
id_token = creds.get("id_token")
|
|
794
|
+
if id_token:
|
|
795
|
+
try:
|
|
796
|
+
email = _decode_jwt_payload(id_token).get("email")
|
|
797
|
+
except Exception:
|
|
798
|
+
email = None
|
|
799
|
+
tier = None
|
|
800
|
+
try:
|
|
801
|
+
loaded = _gemini_load_code_assist(
|
|
802
|
+
access_token, base_url=_ANTIGRAVITY_CLOUDCODE_BASE, ide_type="ANTIGRAVITY"
|
|
803
|
+
)
|
|
804
|
+
tier_id = (loaded.get("currentTier") or {}).get("id") or ""
|
|
805
|
+
tier = {"standard-tier": "Paid", "legacy-tier": "Legacy", "free-tier": "Free"}.get(
|
|
806
|
+
tier_id
|
|
807
|
+
)
|
|
808
|
+
except Exception:
|
|
809
|
+
pass
|
|
810
|
+
return {"email": email, "tier": tier}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _google_code_assist_probe(base_url: str, ide_type: str) -> dict:
|
|
814
|
+
creds = _gemini_load_creds()
|
|
815
|
+
expiry = creds.get("expiry_date")
|
|
816
|
+
if isinstance(expiry, (int, float)) and expiry - _now_ms() < 60_000:
|
|
817
|
+
creds = _gemini_refresh(creds)
|
|
818
|
+
|
|
819
|
+
access_token = creds["access_token"]
|
|
820
|
+
|
|
821
|
+
try:
|
|
822
|
+
loaded = _gemini_load_code_assist(access_token, base_url=base_url, ide_type=ide_type)
|
|
823
|
+
except RuntimeError as e:
|
|
824
|
+
if "HTTP 401" in str(e):
|
|
825
|
+
creds = _gemini_refresh(creds)
|
|
826
|
+
access_token = creds["access_token"]
|
|
827
|
+
loaded = _gemini_load_code_assist(access_token, base_url=base_url, ide_type=ide_type)
|
|
828
|
+
else:
|
|
829
|
+
raise
|
|
830
|
+
|
|
831
|
+
# cloudaicompanionProject is sometimes a string, sometimes {id: ...}
|
|
832
|
+
project_node = loaded.get("cloudaicompanionProject")
|
|
833
|
+
project_id: str | None = None
|
|
834
|
+
if isinstance(project_node, str):
|
|
835
|
+
project_id = project_node
|
|
836
|
+
elif isinstance(project_node, dict):
|
|
837
|
+
project_id = project_node.get("id")
|
|
838
|
+
|
|
839
|
+
tier_node = loaded.get("currentTier") or {}
|
|
840
|
+
tier_id = tier_node.get("id") or ""
|
|
841
|
+
quota = _gemini_retrieve_quota(access_token, project_id, base_url=base_url)
|
|
842
|
+
|
|
843
|
+
# Group buckets by display label, take the *minimum* remaining fraction
|
|
844
|
+
# (most-constrained limit per model).
|
|
845
|
+
grouped: dict[str, dict] = {}
|
|
846
|
+
for bucket in quota.get("buckets") or []:
|
|
847
|
+
model_id = bucket.get("modelId") or bucket.get("model") or ""
|
|
848
|
+
label = _gemini_categorize_model(model_id)
|
|
849
|
+
if not label:
|
|
850
|
+
continue
|
|
851
|
+
frac = bucket.get("remainingFraction")
|
|
852
|
+
if frac is None:
|
|
853
|
+
continue
|
|
854
|
+
pct_left = max(0, min(100, int(round(float(frac) * 100))))
|
|
855
|
+
reset_iso = bucket.get("resetTime")
|
|
856
|
+
existing = grouped.get(label)
|
|
857
|
+
if existing is None or pct_left < existing["pct_left"]:
|
|
858
|
+
grouped[label] = {
|
|
859
|
+
"name": label,
|
|
860
|
+
"pct_left": pct_left,
|
|
861
|
+
"reset_at_iso": reset_iso,
|
|
862
|
+
"reset_relative": _format_relative(reset_iso),
|
|
863
|
+
"forecast": None,
|
|
864
|
+
"reserve_pct": None,
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
windows = [grouped[k] for k in ("Pro", "Flash", "Flash Lite") if k in grouped]
|
|
868
|
+
|
|
869
|
+
# tier mapping
|
|
870
|
+
tier: str | None = None
|
|
871
|
+
if tier_id == "standard-tier":
|
|
872
|
+
tier = "Paid"
|
|
873
|
+
elif tier_id == "legacy-tier":
|
|
874
|
+
tier = "Legacy"
|
|
875
|
+
elif tier_id == "free-tier":
|
|
876
|
+
tier = "Free"
|
|
877
|
+
|
|
878
|
+
# account email from id_token if present
|
|
879
|
+
email = None
|
|
880
|
+
id_token = creds.get("id_token")
|
|
881
|
+
if id_token:
|
|
882
|
+
try:
|
|
883
|
+
email = _decode_jwt_payload(id_token).get("email")
|
|
884
|
+
except Exception:
|
|
885
|
+
email = None
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
"status": "ok",
|
|
889
|
+
"account_email": email,
|
|
890
|
+
"tier": tier,
|
|
891
|
+
"windows": windows,
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
# ---- Aggregator -------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
# gemini-cli's probe still works while its creds file exists, but the CLI is
|
|
899
|
+
# retired locally — Antigravity (agy) replaces it in the default probe set.
|
|
900
|
+
PROBES = {
|
|
901
|
+
"codex": codex_probe,
|
|
902
|
+
"claude": claude_probe,
|
|
903
|
+
"agy": antigravity_probe,
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def refresh_all(quotas_path: Path) -> dict:
|
|
908
|
+
previous: dict = {}
|
|
909
|
+
if quotas_path.exists():
|
|
910
|
+
try:
|
|
911
|
+
previous = json.loads(quotas_path.read_text(encoding="utf-8"))
|
|
912
|
+
except (OSError, json.JSONDecodeError):
|
|
913
|
+
previous = {}
|
|
914
|
+
|
|
915
|
+
out: dict = {
|
|
916
|
+
"fetched_at": datetime.now(UTC).isoformat(),
|
|
917
|
+
"providers": {},
|
|
918
|
+
}
|
|
919
|
+
for name, probe in PROBES.items():
|
|
920
|
+
last_success = (
|
|
921
|
+
(previous.get("providers") or {}).get(name, {}).get("last_success")
|
|
922
|
+
)
|
|
923
|
+
try:
|
|
924
|
+
result = probe()
|
|
925
|
+
if result.get("status") == "ok":
|
|
926
|
+
result["last_success"] = out["fetched_at"]
|
|
927
|
+
else:
|
|
928
|
+
result["last_success"] = last_success
|
|
929
|
+
out["providers"][name] = result
|
|
930
|
+
except Exception as exc:
|
|
931
|
+
out["providers"][name] = {
|
|
932
|
+
"status": "error",
|
|
933
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
934
|
+
"last_success": last_success,
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
tmp = quotas_path.with_suffix(quotas_path.suffix + ".tmp")
|
|
938
|
+
tmp.write_text(json.dumps(out, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
939
|
+
tmp.replace(quotas_path)
|
|
940
|
+
return out
|