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/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