agent-peer 0.2.1__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.
- agent_peer/__init__.py +3 -0
- agent_peer/__main__.py +4 -0
- agent_peer/agy_live.py +102 -0
- agent_peer/agy_status.py +148 -0
- agent_peer/claude_status.py +195 -0
- agent_peer/cli.py +273 -0
- agent_peer/inbox.py +124 -0
- agent_peer/listener.py +306 -0
- agent_peer/logs.py +354 -0
- agent_peer/protocol.py +130 -0
- agent_peer/registry.py +132 -0
- agent_peer/sender.py +63 -0
- agent_peer-0.2.1.dist-info/METADATA +170 -0
- agent_peer-0.2.1.dist-info/RECORD +17 -0
- agent_peer-0.2.1.dist-info/WHEEL +4 -0
- agent_peer-0.2.1.dist-info/entry_points.txt +2 -0
- agent_peer-0.2.1.dist-info/licenses/LICENSE +21 -0
agent_peer/__init__.py
ADDED
agent_peer/__main__.py
ADDED
agent_peer/agy_live.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""On-demand fetch of agy's 5-hour quota via Google's internal
|
|
2
|
+
fetchAvailableModels API. See docs/status.md for the full rationale."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
QUOTA_API = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"
|
|
14
|
+
KEYCHAIN_PREFIX = "go-keyring-base64:"
|
|
15
|
+
|
|
16
|
+
# Required so the backend recognizes the caller as Antigravity — without
|
|
17
|
+
# these, Gemini's own numbers still come back but Claude/GPT models 404.
|
|
18
|
+
_HEADERS = {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
"User-Agent": (
|
|
21
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
22
|
+
"Antigravity/1.0.0 Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36"
|
|
23
|
+
),
|
|
24
|
+
"Client-Metadata": '{"ideType":"ANTIGRAVITY","platform":"WINDOWS","pluginType":"GEMINI"}',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_GEMINI_PROVIDER = "API_PROVIDER_GOOGLE_GEMINI"
|
|
28
|
+
_THIRD_PARTY_PROVIDERS = {"API_PROVIDER_ANTHROPIC_VERTEX", "API_PROVIDER_OPENAI_VERTEX"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_agy_access_token() -> str | None:
|
|
32
|
+
try:
|
|
33
|
+
raw = subprocess.run(
|
|
34
|
+
["security", "find-generic-password", "-s", "gemini", "-a", "antigravity", "-w"],
|
|
35
|
+
capture_output=True, text=True, timeout=5,
|
|
36
|
+
)
|
|
37
|
+
if raw.returncode != 0 or not raw.stdout.strip():
|
|
38
|
+
return None
|
|
39
|
+
val = raw.stdout.strip()
|
|
40
|
+
token_raw = (
|
|
41
|
+
base64.b64decode(val[len(KEYCHAIN_PREFIX):]).decode("utf-8")
|
|
42
|
+
if val.startswith(KEYCHAIN_PREFIX)
|
|
43
|
+
else val
|
|
44
|
+
)
|
|
45
|
+
return json.loads(token_raw)["token"]["access_token"]
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _reset_in_seconds(reset_time: str | None) -> int | None:
|
|
51
|
+
if not reset_time:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
dt = datetime.strptime(reset_time, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
return max(0, int((dt - datetime.now(timezone.utc)).total_seconds()))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def fetch_live_5h_quota() -> tuple[dict | None, str | None]:
|
|
61
|
+
"""Returns ({"gemini_5h": {...} | None, "claude_gpt_5h": {...} | None}, None)
|
|
62
|
+
on a successful call, or (None, error) if anything went wrong."""
|
|
63
|
+
access_token = _read_agy_access_token()
|
|
64
|
+
if not access_token:
|
|
65
|
+
return None, "No agy OAuth token found in keychain."
|
|
66
|
+
|
|
67
|
+
req = urllib.request.Request(
|
|
68
|
+
QUOTA_API,
|
|
69
|
+
data=b"{}",
|
|
70
|
+
headers={**_HEADERS, "Authorization": f"Bearer {access_token}"},
|
|
71
|
+
method="POST",
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
75
|
+
data = json.loads(resp.read().decode())
|
|
76
|
+
except urllib.error.HTTPError as e:
|
|
77
|
+
return None, f"fetchAvailableModels failed: HTTP {e.code}"
|
|
78
|
+
except Exception as e:
|
|
79
|
+
return None, f"fetchAvailableModels failed: {e}"
|
|
80
|
+
|
|
81
|
+
# Keep the most-constrained model per group (lowest remainingFraction) -
|
|
82
|
+
# a quota-exempt model reports 100%/no-reset, masking a partially-used pool.
|
|
83
|
+
buckets: dict[str, list[dict]] = {"gemini_5h": [], "claude_gpt_5h": []}
|
|
84
|
+
for model in (data.get("models") or {}).values():
|
|
85
|
+
quota_info = model.get("quotaInfo")
|
|
86
|
+
if not quota_info:
|
|
87
|
+
continue
|
|
88
|
+
entry = {
|
|
89
|
+
"remaining_pct": round((quota_info.get("remainingFraction") or 0) * 100, 1),
|
|
90
|
+
"resets_in_s": _reset_in_seconds(quota_info.get("resetTime")),
|
|
91
|
+
}
|
|
92
|
+
provider = model.get("apiProvider", "")
|
|
93
|
+
if provider == _GEMINI_PROVIDER:
|
|
94
|
+
buckets["gemini_5h"].append(entry)
|
|
95
|
+
elif provider in _THIRD_PARTY_PROVIDERS:
|
|
96
|
+
buckets["claude_gpt_5h"].append(entry)
|
|
97
|
+
|
|
98
|
+
result = {
|
|
99
|
+
key: (min(entries, key=lambda e: e["remaining_pct"]) if entries else None)
|
|
100
|
+
for key, entries in buckets.items()
|
|
101
|
+
}
|
|
102
|
+
return result, None
|
agent_peer/agy_status.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Reads agy's cached quota/context snapshot and freshens the 5h quota
|
|
2
|
+
numbers live (see agy_live.py). See docs/status.md for the full rationale."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from . import agy_live
|
|
10
|
+
from .protocol import AGENT_PEER_DIR
|
|
11
|
+
|
|
12
|
+
AGY_STATUS_FILE = os.path.join(AGENT_PEER_DIR, "agy_status.json")
|
|
13
|
+
|
|
14
|
+
STALE_AFTER_SECONDS = 15 * 60 # agy only writes this when it actually renders a statusline
|
|
15
|
+
|
|
16
|
+
# Raw payload keys -> the short, JSON-friendly names used in the trimmed dict.
|
|
17
|
+
_QUOTA_KEYS = {
|
|
18
|
+
"gemini_5h": "gemini-5h",
|
|
19
|
+
"gemini_weekly": "gemini-weekly",
|
|
20
|
+
"claude_gpt_5h": "3p-5h",
|
|
21
|
+
"claude_gpt_weekly": "3p-weekly",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_agy_status():
|
|
26
|
+
"""Returns (payload: dict | None, age_seconds: float | None, error: str | None)."""
|
|
27
|
+
if not os.path.exists(AGY_STATUS_FILE):
|
|
28
|
+
return None, None, "No agy status captured yet — open an interactive `agy` session at least once."
|
|
29
|
+
try:
|
|
30
|
+
with open(AGY_STATUS_FILE, "r", encoding="utf-8") as f:
|
|
31
|
+
data = json.load(f)
|
|
32
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
33
|
+
return None, None, f"Could not read {AGY_STATUS_FILE}: {e}"
|
|
34
|
+
|
|
35
|
+
captured_at = data.get("captured_at")
|
|
36
|
+
age_seconds = None
|
|
37
|
+
if captured_at:
|
|
38
|
+
try:
|
|
39
|
+
dt = datetime.strptime(captured_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
40
|
+
age_seconds = time.time() - dt.timestamp()
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
return data.get("payload"), age_seconds, None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fmt_age(seconds):
|
|
48
|
+
if seconds is None:
|
|
49
|
+
return "unknown"
|
|
50
|
+
if seconds < 60:
|
|
51
|
+
return f"{int(seconds)}s ago"
|
|
52
|
+
if seconds < 3600:
|
|
53
|
+
return f"{int(seconds // 60)}m ago"
|
|
54
|
+
return f"{seconds / 3600:.1f}h ago"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fmt_duration(seconds):
|
|
58
|
+
if seconds is None:
|
|
59
|
+
return "?"
|
|
60
|
+
seconds = int(seconds)
|
|
61
|
+
h, m = seconds // 3600, (seconds % 3600) // 60
|
|
62
|
+
return f"{h}h{m}m" if h else f"{m}m"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_agy_status_dict() -> dict:
|
|
66
|
+
"""JSON shape where remaining_pct is directly comparable to
|
|
67
|
+
get_claude_status_dict()'s, regardless of each provider's raw convention."""
|
|
68
|
+
payload, age_seconds, error = read_agy_status()
|
|
69
|
+
if error:
|
|
70
|
+
return {"error": error}
|
|
71
|
+
|
|
72
|
+
ctx = payload.get("context_window") or {}
|
|
73
|
+
raw_quota = payload.get("quota") or {}
|
|
74
|
+
|
|
75
|
+
quota = {}
|
|
76
|
+
for short_name, raw_key in _QUOTA_KEYS.items():
|
|
77
|
+
entry = raw_quota.get(raw_key)
|
|
78
|
+
quota[short_name] = (
|
|
79
|
+
{
|
|
80
|
+
"remaining_pct": round((entry.get("remaining_fraction") or 0) * 100, 1),
|
|
81
|
+
"resets_in_s": entry.get("reset_in_seconds"),
|
|
82
|
+
"source": "cached",
|
|
83
|
+
}
|
|
84
|
+
if entry
|
|
85
|
+
else None
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Freshen just the 5h numbers live; weekly + context have no API source.
|
|
89
|
+
live, live_error = agy_live.fetch_live_5h_quota()
|
|
90
|
+
if live:
|
|
91
|
+
for key in ("gemini_5h", "claude_gpt_5h"):
|
|
92
|
+
if live.get(key):
|
|
93
|
+
quota[key] = {**live[key], "source": "live"}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
"email": payload.get("email"),
|
|
97
|
+
"plan": payload.get("plan_tier"),
|
|
98
|
+
"model": (payload.get("model") or {}).get("display_name"),
|
|
99
|
+
"snapshot_age_s": round(age_seconds, 1) if age_seconds is not None else None,
|
|
100
|
+
"live_5h_fetch_error": live_error,
|
|
101
|
+
"context": {
|
|
102
|
+
"used_pct": ctx.get("used_percentage", 0),
|
|
103
|
+
"input_tokens": ctx.get("total_input_tokens", 0),
|
|
104
|
+
"output_tokens": ctx.get("total_output_tokens", 0),
|
|
105
|
+
"window_size": ctx.get("context_window_size", 0),
|
|
106
|
+
},
|
|
107
|
+
"quota": quota,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def format_agy_status(as_json: bool = False) -> str:
|
|
112
|
+
status = get_agy_status_dict()
|
|
113
|
+
if "error" in status:
|
|
114
|
+
return status["error"]
|
|
115
|
+
if as_json:
|
|
116
|
+
return json.dumps(status, indent=2)
|
|
117
|
+
|
|
118
|
+
age_s = status["snapshot_age_s"]
|
|
119
|
+
ctx = status["context"]
|
|
120
|
+
lines = [
|
|
121
|
+
f"agy status (email: {status['email'] or '?'} · plan: {status['plan'] or '?'} · model: {status['model'] or '?'})",
|
|
122
|
+
f" snapshot age: {_fmt_age(age_s)}"
|
|
123
|
+
+ (" ⚠ stale (agy not run recently)" if (age_s or 0) > STALE_AFTER_SECONDS else ""),
|
|
124
|
+
"",
|
|
125
|
+
f" context: {ctx['used_pct']:.1f}% used "
|
|
126
|
+
f"({ctx['input_tokens']} in / {ctx['output_tokens']} out / {ctx['window_size']} window)"
|
|
127
|
+
" [cached, no live source]",
|
|
128
|
+
"",
|
|
129
|
+
" quota:",
|
|
130
|
+
]
|
|
131
|
+
labels = {
|
|
132
|
+
"gemini_5h": "gemini 5h ",
|
|
133
|
+
"gemini_weekly": "gemini week",
|
|
134
|
+
"claude_gpt_5h": "claude/gpt 5h",
|
|
135
|
+
"claude_gpt_weekly": "claude/gpt wk",
|
|
136
|
+
}
|
|
137
|
+
for key, label in labels.items():
|
|
138
|
+
q = status["quota"].get(key)
|
|
139
|
+
if not q:
|
|
140
|
+
lines.append(f" {label} n/a")
|
|
141
|
+
continue
|
|
142
|
+
tag = "live" if q.get("source") == "live" else "cached"
|
|
143
|
+
lines.append(
|
|
144
|
+
f" {label} {q['remaining_pct']:>5.1f}% remaining (resets in {fmt_duration(q['resets_in_s'])}) [{tag}]"
|
|
145
|
+
)
|
|
146
|
+
if status.get("live_5h_fetch_error"):
|
|
147
|
+
lines.append(f"\n ⚠ live 5h refresh failed, showing cached numbers instead: {status['live_5h_fetch_error']}")
|
|
148
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Fetches Claude Code's own usage/quota from Anthropic's API using its
|
|
2
|
+
OAuth token from the macOS Keychain. See docs/status.md for the rationale."""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import unicodedata
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
14
|
+
TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
|
|
15
|
+
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
16
|
+
DEFAULT_KEYCHAIN_SERVICE = "Claude Code-credentials"
|
|
17
|
+
CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _hashed_keychain_service(config_dir: str) -> str:
|
|
21
|
+
"""Mirrors Claude Code's own hashing of CLAUDE_CONFIG_DIR - must hash the
|
|
22
|
+
exact exported string, not a resolved path, or it names an empty keychain item."""
|
|
23
|
+
normalized = unicodedata.normalize("NFC", config_dir)
|
|
24
|
+
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]
|
|
25
|
+
return f"{DEFAULT_KEYCHAIN_SERVICE}-{digest}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _active_keychain_services() -> list[str]:
|
|
29
|
+
"""Which keychain service holds this environment's active credential,
|
|
30
|
+
following the same CLAUDE_CONFIG_DIR resolution order Claude Code itself uses."""
|
|
31
|
+
secure_env = os.environ.get("CLAUDE_SECURESTORAGE_CONFIG_DIR")
|
|
32
|
+
if secure_env is not None:
|
|
33
|
+
return [DEFAULT_KEYCHAIN_SERVICE] if not secure_env else [_hashed_keychain_service(secure_env)]
|
|
34
|
+
|
|
35
|
+
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
36
|
+
if not config_dir:
|
|
37
|
+
return [DEFAULT_KEYCHAIN_SERVICE]
|
|
38
|
+
return [_hashed_keychain_service(config_dir), DEFAULT_KEYCHAIN_SERVICE]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_keychain(service: str) -> dict | None:
|
|
42
|
+
try:
|
|
43
|
+
user = os.environ.get("USER") or __import__("pwd").getpwuid(os.geteuid()).pw_name
|
|
44
|
+
raw = subprocess.run(
|
|
45
|
+
["security", "find-generic-password", "-s", service, "-a", user, "-w"],
|
|
46
|
+
capture_output=True, text=True, timeout=5,
|
|
47
|
+
)
|
|
48
|
+
if raw.returncode == 0 and raw.stdout.strip():
|
|
49
|
+
return json.loads(raw.stdout).get("claudeAiOauth")
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_credentials() -> dict | None:
|
|
56
|
+
for service in _active_keychain_services():
|
|
57
|
+
oauth = _read_keychain(service)
|
|
58
|
+
if oauth:
|
|
59
|
+
return oauth
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(CREDENTIALS_FILE.read_text()).get("claudeAiOauth")
|
|
62
|
+
except (OSError, json.JSONDecodeError):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _refresh(refresh_token: str) -> str | None:
|
|
67
|
+
body = json.dumps(
|
|
68
|
+
{"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": CLIENT_ID}
|
|
69
|
+
).encode()
|
|
70
|
+
req = urllib.request.Request(
|
|
71
|
+
TOKEN_URL, data=body, headers={"Content-Type": "application/json"}, method="POST"
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
75
|
+
return json.loads(resp.read().decode()).get("access_token")
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _request_usage(access_token: str) -> dict:
|
|
81
|
+
req = urllib.request.Request(
|
|
82
|
+
USAGE_URL,
|
|
83
|
+
headers={
|
|
84
|
+
"Authorization": f"Bearer {access_token}",
|
|
85
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
86
|
+
"User-Agent": "agent-peer/0.1.0",
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
90
|
+
return json.loads(resp.read().decode())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _fetch_email(access_token: str) -> str | None:
|
|
94
|
+
"""Whose token this is — confirms which active account got read, rather
|
|
95
|
+
than trusting the keychain-slot resolution silently."""
|
|
96
|
+
req = urllib.request.Request(
|
|
97
|
+
"https://api.anthropic.com/api/oauth/profile",
|
|
98
|
+
headers={"Authorization": f"Bearer {access_token}", "User-Agent": "agent-peer/0.1.0"},
|
|
99
|
+
)
|
|
100
|
+
try:
|
|
101
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
102
|
+
account = json.loads(resp.read().decode()).get("account") or {}
|
|
103
|
+
return account.get("email")
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fetch_claude_usage():
|
|
109
|
+
"""Returns (usage: dict | None, meta: dict | None, error: str | None)."""
|
|
110
|
+
oauth = _read_credentials()
|
|
111
|
+
if not oauth or not oauth.get("accessToken"):
|
|
112
|
+
return None, None, "No Claude Code credentials found (keychain or ~/.claude/.credentials.json)."
|
|
113
|
+
|
|
114
|
+
access_token = oauth["accessToken"]
|
|
115
|
+
try:
|
|
116
|
+
data = _request_usage(access_token)
|
|
117
|
+
except urllib.error.HTTPError as e:
|
|
118
|
+
if e.code == 401 and oauth.get("refreshToken"):
|
|
119
|
+
fresh = _refresh(oauth["refreshToken"])
|
|
120
|
+
if not fresh:
|
|
121
|
+
return None, None, "Access token expired and refresh failed."
|
|
122
|
+
access_token = fresh
|
|
123
|
+
try:
|
|
124
|
+
data = _request_usage(access_token)
|
|
125
|
+
except Exception as e2:
|
|
126
|
+
return None, None, f"Usage fetch failed after refresh: {e2}"
|
|
127
|
+
else:
|
|
128
|
+
return None, None, f"Usage fetch failed: HTTP {e.code}"
|
|
129
|
+
except Exception as e:
|
|
130
|
+
return None, None, f"Usage fetch failed: {e}"
|
|
131
|
+
|
|
132
|
+
meta = {"subscription_type": oauth.get("subscriptionType"), "email": _fetch_email(access_token)}
|
|
133
|
+
return data, meta, None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _reset_in_seconds(resets_at: str | None) -> int | None:
|
|
137
|
+
if not resets_at:
|
|
138
|
+
return None
|
|
139
|
+
from datetime import datetime, timezone
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
dt = datetime.fromisoformat(resets_at)
|
|
143
|
+
except ValueError:
|
|
144
|
+
return None
|
|
145
|
+
return max(0, int((dt - datetime.now(timezone.utc)).total_seconds()))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_claude_status_dict() -> dict:
|
|
149
|
+
"""JSON shape normalized to remaining_pct, flipping Anthropic's raw
|
|
150
|
+
utilization% and dropping unreleased-feature placeholder fields."""
|
|
151
|
+
data, meta, error = fetch_claude_usage()
|
|
152
|
+
if error:
|
|
153
|
+
return {"error": error}
|
|
154
|
+
|
|
155
|
+
def window(entry: dict) -> dict:
|
|
156
|
+
used_pct = entry.get("utilization") or 0
|
|
157
|
+
return {
|
|
158
|
+
"remaining_pct": round(100 - used_pct, 1),
|
|
159
|
+
"resets_in_s": _reset_in_seconds(entry.get("resets_at")),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
quota = {
|
|
163
|
+
"session_5h": window(data.get("five_hour") or {}),
|
|
164
|
+
"weekly_all": window(data.get("seven_day") or {}),
|
|
165
|
+
}
|
|
166
|
+
for lim in data.get("limits") or []:
|
|
167
|
+
if lim.get("kind") != "weekly_scoped":
|
|
168
|
+
continue
|
|
169
|
+
model = ((lim.get("scope") or {}).get("model") or {}).get("display_name")
|
|
170
|
+
if not model:
|
|
171
|
+
continue
|
|
172
|
+
quota[f"weekly_{model.lower()}"] = window({"utilization": lim.get("percent"), "resets_at": lim.get("resets_at")})
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
"email": meta.get("email"),
|
|
176
|
+
"plan": meta.get("subscription_type"),
|
|
177
|
+
"quota": quota,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def format_claude_status(as_json: bool = False) -> str:
|
|
182
|
+
from .agy_status import fmt_duration
|
|
183
|
+
|
|
184
|
+
status = get_claude_status_dict()
|
|
185
|
+
if "error" in status:
|
|
186
|
+
return status["error"]
|
|
187
|
+
if as_json:
|
|
188
|
+
return json.dumps(status, indent=2)
|
|
189
|
+
|
|
190
|
+
lines = [f"claude status (email: {status['email'] or '?'} · plan: {status['plan'] or '?'})", "", " quota:"]
|
|
191
|
+
labels = {"session_5h": "session 5h ", "weekly_all": "weekly all "}
|
|
192
|
+
for key, q in status["quota"].items():
|
|
193
|
+
label = labels.get(key, f"weekly {key.removeprefix('weekly_'):<5}")
|
|
194
|
+
lines.append(f" {label} {q['remaining_pct']:>5.1f}% remaining (resets in {fmt_duration(q['resets_in_s'])})")
|
|
195
|
+
return "\n".join(lines)
|