ccusage 0.1.6__tar.gz → 0.1.8__tar.gz
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.
- {ccusage-0.1.6 → ccusage-0.1.8}/PKG-INFO +11 -2
- {ccusage-0.1.6 → ccusage-0.1.8}/README.md +10 -1
- {ccusage-0.1.6 → ccusage-0.1.8}/pyproject.toml +1 -1
- {ccusage-0.1.6 → ccusage-0.1.8}/src/ccusage/__init__.py +240 -57
- ccusage-0.1.8/tests/test_ccusage.py +199 -0
- {ccusage-0.1.6 → ccusage-0.1.8}/uv.lock +1 -1
- {ccusage-0.1.6 → ccusage-0.1.8}/.github/workflows/publish.yml +0 -0
- {ccusage-0.1.6 → ccusage-0.1.8}/.gitignore +0 -0
- {ccusage-0.1.6 → ccusage-0.1.8}/.python-version +0 -0
- {ccusage-0.1.6 → ccusage-0.1.8}/src/ccusage/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ccusage
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Claude Code usage monitor — fetches rate limits from Anthropic's API
|
|
5
5
|
Project-URL: Homepage, https://github.com/wakamex/ccusage
|
|
6
6
|
Project-URL: Source, https://github.com/wakamex/ccusage
|
|
@@ -94,6 +94,15 @@ Claude Code gets rate limit data from two places:
|
|
|
94
94
|
| `seven_day_opus` | `seven_day_opus` | Rolling 7-day Opus-specific window |
|
|
95
95
|
| `overage` | `extra_usage` | Extra/overage usage (if enabled) |
|
|
96
96
|
|
|
97
|
+
The flat `seven_day_*` keys above are all `null` now — the live quota data
|
|
98
|
+
lives in a structured `limits` array, which ccusage reads. Each entry is
|
|
99
|
+
self-describing: `kind` (`session` / `weekly_all` / `weekly_scoped`),
|
|
100
|
+
`percent`, `resets_at`, and for scoped limits a `scope.model.display_name`.
|
|
101
|
+
Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
|
|
102
|
+
keyed by their model name, so any new one is auto-detected and displayed with
|
|
103
|
+
no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
|
|
104
|
+
statusline `fab:`).
|
|
105
|
+
|
|
97
106
|
### API response format
|
|
98
107
|
|
|
99
108
|
```
|
|
@@ -145,7 +154,7 @@ The OAuth token lives at `~/.claude/.credentials.json`:
|
|
|
145
154
|
}
|
|
146
155
|
```
|
|
147
156
|
|
|
148
|
-
The access token expires roughly hourly.
|
|
157
|
+
The access token expires roughly hourly. When it's expired (or rejected with a 401/403), ccusage refreshes it itself using the `refreshToken` and writes the rotated credentials back to `.credentials.json` — the same flow Claude Code uses, so the two stay in sync and no active Claude Code session is needed.
|
|
149
158
|
|
|
150
159
|
### Warning thresholds (from cli.js)
|
|
151
160
|
|
|
@@ -85,6 +85,15 @@ Claude Code gets rate limit data from two places:
|
|
|
85
85
|
| `seven_day_opus` | `seven_day_opus` | Rolling 7-day Opus-specific window |
|
|
86
86
|
| `overage` | `extra_usage` | Extra/overage usage (if enabled) |
|
|
87
87
|
|
|
88
|
+
The flat `seven_day_*` keys above are all `null` now — the live quota data
|
|
89
|
+
lives in a structured `limits` array, which ccusage reads. Each entry is
|
|
90
|
+
self-describing: `kind` (`session` / `weekly_all` / `weekly_scoped`),
|
|
91
|
+
`percent`, `resets_at`, and for scoped limits a `scope.model.display_name`.
|
|
92
|
+
Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
|
|
93
|
+
keyed by their model name, so any new one is auto-detected and displayed with
|
|
94
|
+
no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
|
|
95
|
+
statusline `fab:`).
|
|
96
|
+
|
|
88
97
|
### API response format
|
|
89
98
|
|
|
90
99
|
```
|
|
@@ -136,7 +145,7 @@ The OAuth token lives at `~/.claude/.credentials.json`:
|
|
|
136
145
|
}
|
|
137
146
|
```
|
|
138
147
|
|
|
139
|
-
The access token expires roughly hourly.
|
|
148
|
+
The access token expires roughly hourly. When it's expired (or rejected with a 401/403), ccusage refreshes it itself using the `refreshToken` and writes the rotated credentials back to `.credentials.json` — the same flow Claude Code uses, so the two stay in sync and no active Claude Code session is needed.
|
|
140
149
|
|
|
141
150
|
### Warning thresholds (from cli.js)
|
|
142
151
|
|
|
@@ -15,10 +15,12 @@ Usage:
|
|
|
15
15
|
|
|
16
16
|
import argparse
|
|
17
17
|
import json
|
|
18
|
+
import os
|
|
18
19
|
import signal
|
|
19
20
|
import subprocess
|
|
20
21
|
import sys
|
|
21
22
|
import time
|
|
23
|
+
import urllib.error
|
|
22
24
|
import urllib.request
|
|
23
25
|
from datetime import datetime, timezone
|
|
24
26
|
from pathlib import Path
|
|
@@ -69,6 +71,8 @@ CLAUDE_DIR = Path.home() / ".claude"
|
|
|
69
71
|
CREDENTIALS_FILE = _resolve_claude_path(".credentials.json")
|
|
70
72
|
USAGE_FILE = _resolve_claude_path("usage-limits.json")
|
|
71
73
|
DAEMON_INTERVAL = 300 # 5 minutes
|
|
74
|
+
TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
|
75
|
+
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Claude Code's public OAuth client
|
|
72
76
|
|
|
73
77
|
|
|
74
78
|
def get_credentials() -> dict | None:
|
|
@@ -90,10 +94,59 @@ def get_plan(creds: dict | None = None) -> str:
|
|
|
90
94
|
return tier.removeprefix("default_claude_")
|
|
91
95
|
|
|
92
96
|
|
|
97
|
+
def refresh_credentials(creds: dict) -> dict:
|
|
98
|
+
"""Refresh the OAuth access token and persist updated credentials.
|
|
99
|
+
|
|
100
|
+
Anthropic rotates refresh tokens, so the new refreshToken MUST be written
|
|
101
|
+
back to .credentials.json or Claude Code's stored one goes stale and the
|
|
102
|
+
user gets logged out.
|
|
103
|
+
"""
|
|
104
|
+
oauth = creds.get("claudeAiOauth", {})
|
|
105
|
+
refresh_token = oauth.get("refreshToken")
|
|
106
|
+
if not refresh_token:
|
|
107
|
+
raise RuntimeError("OAuth token expired and no refresh token — open Claude Code to log in")
|
|
108
|
+
|
|
109
|
+
payload = json.dumps({
|
|
110
|
+
"grant_type": "refresh_token",
|
|
111
|
+
"refresh_token": refresh_token,
|
|
112
|
+
"client_id": CLIENT_ID,
|
|
113
|
+
}).encode()
|
|
114
|
+
req = urllib.request.Request(
|
|
115
|
+
TOKEN_URL,
|
|
116
|
+
data=payload,
|
|
117
|
+
headers={"Content-Type": "application/json"},
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
121
|
+
result = json.loads(resp.read())
|
|
122
|
+
except urllib.error.HTTPError as e:
|
|
123
|
+
raise RuntimeError(f"Token refresh failed ({e.code}) — open Claude Code to refresh it") from e
|
|
124
|
+
|
|
125
|
+
oauth = dict(oauth)
|
|
126
|
+
oauth["accessToken"] = result["access_token"]
|
|
127
|
+
if result.get("refresh_token"):
|
|
128
|
+
oauth["refreshToken"] = result["refresh_token"]
|
|
129
|
+
oauth["expiresAt"] = int(time.time() * 1000 + int(result.get("expires_in", 3600)) * 1000)
|
|
130
|
+
updated = dict(creds)
|
|
131
|
+
updated["claudeAiOauth"] = oauth
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
tmp = CREDENTIALS_FILE.parent / (CREDENTIALS_FILE.name + ".tmp")
|
|
135
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
136
|
+
with os.fdopen(fd, "w") as f:
|
|
137
|
+
f.write(json.dumps(updated))
|
|
138
|
+
os.replace(tmp, CREDENTIALS_FILE)
|
|
139
|
+
except OSError as e:
|
|
140
|
+
print(f"Warning: refreshed token but could not write {CREDENTIALS_FILE}: {e}", file=sys.stderr)
|
|
141
|
+
|
|
142
|
+
return updated
|
|
143
|
+
|
|
144
|
+
|
|
93
145
|
def fetch_usage() -> dict:
|
|
94
146
|
"""Fetch usage from Anthropic's /api/oauth/usage endpoint.
|
|
95
147
|
|
|
96
|
-
|
|
148
|
+
Reads the OAuth token from ~/.claude/.credentials.json, auto-refreshing it
|
|
149
|
+
(and persisting the rotated credentials) when expired or rejected.
|
|
97
150
|
The key header is `anthropic-beta: oauth-2025-04-20` — without it, the
|
|
98
151
|
endpoint returns an auth error.
|
|
99
152
|
|
|
@@ -115,42 +168,121 @@ def fetch_usage() -> dict:
|
|
|
115
168
|
if not token:
|
|
116
169
|
raise RuntimeError("No OAuth access token in credentials")
|
|
117
170
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
171
|
+
# Refresh proactively if expired (or about to), then retry once on 401/403
|
|
172
|
+
# in case the token was revoked early.
|
|
173
|
+
if time.time() * 1000 > oauth.get("expiresAt", 0) - 60_000:
|
|
174
|
+
creds = refresh_credentials(creds)
|
|
175
|
+
token = creds["claudeAiOauth"]["accessToken"]
|
|
176
|
+
|
|
177
|
+
for attempt in range(2):
|
|
178
|
+
req = urllib.request.Request(
|
|
179
|
+
"https://api.anthropic.com/api/oauth/usage",
|
|
180
|
+
headers={
|
|
181
|
+
"Authorization": f"Bearer {token}",
|
|
182
|
+
"Content-Type": "application/json",
|
|
183
|
+
"User-Agent": "claude-code/2.1.71",
|
|
184
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
189
|
+
return json.loads(resp.read())
|
|
190
|
+
except urllib.error.HTTPError as e:
|
|
191
|
+
if e.code in (401, 403) and attempt == 0:
|
|
192
|
+
creds = refresh_credentials(creds)
|
|
193
|
+
token = creds["claudeAiOauth"]["accessToken"]
|
|
194
|
+
else:
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
raise RuntimeError("Failed to fetch usage after token refresh")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Top-level keys in the cached usage dict that are NOT rate-limit buckets.
|
|
201
|
+
_META_KEYS = {"plan", "source", "updated_at", "extra_usage"}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _bucket_display(short_key: str) -> tuple[str, str]:
|
|
205
|
+
"""Return (full label, statusline abbrev) for a short bucket key.
|
|
206
|
+
|
|
207
|
+
Derived from the key so unknown/newly-added buckets get a reasonable
|
|
208
|
+
label automatically, e.g. "7d_fable" -> ("Week (Fable)", "fab").
|
|
209
|
+
"""
|
|
210
|
+
known = {
|
|
211
|
+
"5h": ("Session (5h)", "5h"),
|
|
212
|
+
"7d": ("Week (all)", "7d"),
|
|
213
|
+
"7d_opus": ("Week (Opus)", "opus"),
|
|
214
|
+
"7d_sonnet": ("Week (Sonnet)", "son"),
|
|
215
|
+
}
|
|
216
|
+
if short_key in known:
|
|
217
|
+
return known[short_key]
|
|
218
|
+
if short_key.startswith("7d_"):
|
|
219
|
+
model = short_key[3:]
|
|
220
|
+
return f"Week ({model.replace('_', ' ').title()})", model[:3]
|
|
221
|
+
return short_key.replace("_", " ").title(), short_key[:4]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _quota_buckets(data: dict):
|
|
225
|
+
"""Yield (short_key, bucket) for each rate-limit bucket in a usage dict.
|
|
226
|
+
|
|
227
|
+
Relies on insertion order (build_usage_json inserts them sorted), so
|
|
228
|
+
callers get a stable, sensible display sequence.
|
|
229
|
+
"""
|
|
230
|
+
for key, val in data.items():
|
|
231
|
+
if key in _META_KEYS:
|
|
232
|
+
continue
|
|
233
|
+
if isinstance(val, dict) and "pct" in val:
|
|
234
|
+
yield key, val
|
|
121
235
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
236
|
+
|
|
237
|
+
def _buckets_from_limits(limits) -> list:
|
|
238
|
+
"""Parse the API's structured `limits` array into (order, short_key, bucket).
|
|
239
|
+
|
|
240
|
+
This is the current API shape. Each entry is self-describing:
|
|
241
|
+
{"kind": "session"|"weekly_all"|"weekly_scoped", "percent": 58,
|
|
242
|
+
"resets_at": "...", "scope": {"model": {"display_name": "Fable"}}}
|
|
243
|
+
Model-scoped weekly limits (Opus, Sonnet, Fable, ...) are keyed by their
|
|
244
|
+
model name, so a newly added one appears automatically.
|
|
245
|
+
"""
|
|
246
|
+
out = []
|
|
247
|
+
for entry in limits:
|
|
248
|
+
if not isinstance(entry, dict):
|
|
249
|
+
continue
|
|
250
|
+
pct = entry.get("percent")
|
|
251
|
+
if pct is None:
|
|
252
|
+
continue
|
|
253
|
+
kind = entry.get("kind")
|
|
254
|
+
if kind == "session":
|
|
255
|
+
short_key, order = "5h", 0
|
|
256
|
+
elif kind == "weekly_all":
|
|
257
|
+
short_key, order = "7d", 1
|
|
258
|
+
elif kind == "weekly_scoped":
|
|
259
|
+
model = (entry.get("scope") or {}).get("model") or {}
|
|
260
|
+
name = (model.get("display_name") or model.get("id") or "scoped").strip()
|
|
261
|
+
short_key = "7d_" + name.lower().replace(" ", "_")
|
|
262
|
+
order = {"7d_opus": 2, "7d_sonnet": 3}.get(short_key, 4)
|
|
263
|
+
else:
|
|
264
|
+
short_key, order = kind or "unknown", 5
|
|
265
|
+
out.append((order, short_key, {"pct": pct, "resets_at": entry.get("resets_at")}))
|
|
266
|
+
return out
|
|
133
267
|
|
|
134
268
|
|
|
135
269
|
def build_usage_json(api_data: dict, plan: str) -> dict:
|
|
136
|
-
"""Transform API response into our cached format.
|
|
270
|
+
"""Transform API response into our cached format.
|
|
271
|
+
|
|
272
|
+
Reads the API's structured `limits` array — every quota it reports (session,
|
|
273
|
+
weekly-all, and per-model weekly windows like Opus/Sonnet/Fable) is included
|
|
274
|
+
and mapped to a short key, so a newly added one appears automatically
|
|
275
|
+
instead of being dropped.
|
|
276
|
+
"""
|
|
137
277
|
result = {
|
|
138
278
|
"plan": plan,
|
|
139
279
|
"source": "api",
|
|
140
280
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
141
281
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
("7d_opus", "seven_day_opus"),
|
|
147
|
-
]:
|
|
148
|
-
bucket = api_data.get(api_key)
|
|
149
|
-
if bucket:
|
|
150
|
-
result[key] = {
|
|
151
|
-
"pct": bucket["utilization"],
|
|
152
|
-
"resets_at": bucket.get("resets_at"),
|
|
153
|
-
}
|
|
282
|
+
limits = api_data.get("limits")
|
|
283
|
+
buckets = _buckets_from_limits(limits) if isinstance(limits, list) else []
|
|
284
|
+
for _, short_key, bucket in sorted(buckets, key=lambda b: b[0]):
|
|
285
|
+
result[short_key] = bucket
|
|
154
286
|
extra = api_data.get("extra_usage")
|
|
155
287
|
if extra:
|
|
156
288
|
result["extra_usage"] = extra
|
|
@@ -162,13 +294,51 @@ def write_usage_file(data: dict):
|
|
|
162
294
|
USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
163
295
|
|
|
164
296
|
|
|
297
|
+
def _read_cache() -> dict | None:
|
|
298
|
+
"""Read the cached usage file, or None if missing/unreadable."""
|
|
299
|
+
try:
|
|
300
|
+
return json.loads(USAGE_FILE.read_text())
|
|
301
|
+
except Exception:
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _is_429(err: Exception) -> bool:
|
|
306
|
+
"""True if the exception is (or wraps) an HTTP 429 rate-limit error."""
|
|
307
|
+
if isinstance(err, urllib.error.HTTPError) and err.code == 429:
|
|
308
|
+
return True
|
|
309
|
+
return "429" in str(err)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _cache_age_str(data: dict) -> str:
|
|
313
|
+
"""Return e.g. ' 3m ago' from a usage dict's updated_at, or '' if unknown."""
|
|
314
|
+
try:
|
|
315
|
+
updated = datetime.fromisoformat(data["updated_at"])
|
|
316
|
+
secs = int((datetime.now(timezone.utc) - updated).total_seconds())
|
|
317
|
+
m = secs // 60
|
|
318
|
+
return f" {m // 60}h{m % 60}m ago" if m >= 60 else f" {m}m ago"
|
|
319
|
+
except Exception:
|
|
320
|
+
return ""
|
|
321
|
+
|
|
322
|
+
|
|
165
323
|
# -- CLI commands --
|
|
166
324
|
|
|
167
325
|
def cmd_status(raw_json=False):
|
|
168
|
-
"""Fetch and display current usage.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
326
|
+
"""Fetch and display current usage.
|
|
327
|
+
|
|
328
|
+
Fetches fresh from the API, but on failure (rate limit, offline, expired
|
|
329
|
+
token) falls back to the last cached usage rather than crashing.
|
|
330
|
+
"""
|
|
331
|
+
stale = False
|
|
332
|
+
try:
|
|
333
|
+
api_data = fetch_usage()
|
|
334
|
+
data = build_usage_json(api_data, get_plan())
|
|
335
|
+
except Exception as e:
|
|
336
|
+
cached = _read_cache()
|
|
337
|
+
if cached is None:
|
|
338
|
+
reason = "rate limited (HTTP 429) — try again shortly" if _is_429(e) else str(e)
|
|
339
|
+
print(f"Could not fetch usage: {reason}", file=sys.stderr)
|
|
340
|
+
sys.exit(1)
|
|
341
|
+
data, stale = cached, True
|
|
172
342
|
|
|
173
343
|
if raw_json:
|
|
174
344
|
print(json.dumps(data, indent=2))
|
|
@@ -201,18 +371,12 @@ def cmd_status(raw_json=False):
|
|
|
201
371
|
except Exception:
|
|
202
372
|
return ""
|
|
203
373
|
|
|
204
|
-
print(f"Plan: {plan}")
|
|
205
|
-
for
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
("
|
|
210
|
-
]:
|
|
211
|
-
bucket = data.get(key)
|
|
212
|
-
if bucket:
|
|
213
|
-
pct = bucket["pct"]
|
|
214
|
-
reset = fmt_reset(bucket.get("resets_at"))
|
|
215
|
-
print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
|
|
374
|
+
print(f"Plan: {data.get('plan', '?')}")
|
|
375
|
+
for key, bucket in _quota_buckets(data):
|
|
376
|
+
label = _bucket_display(key)[0]
|
|
377
|
+
pct = bucket["pct"]
|
|
378
|
+
reset = fmt_reset(bucket.get("resets_at"))
|
|
379
|
+
print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
|
|
216
380
|
|
|
217
381
|
extra = data.get("extra_usage")
|
|
218
382
|
if extra and extra.get("is_enabled"):
|
|
@@ -220,6 +384,30 @@ def cmd_status(raw_json=False):
|
|
|
220
384
|
limit = extra.get("monthly_limit", 0) / 100
|
|
221
385
|
print(f" {'Extra usage':20s} ${used:.2f} / ${limit:.2f}")
|
|
222
386
|
|
|
387
|
+
if stale:
|
|
388
|
+
age = _cache_age_str(data)
|
|
389
|
+
print(f"{D} (cached{age} — live fetch failed){RST}", file=sys.stderr)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def cmd_refresh():
|
|
393
|
+
"""Fetch fresh usage from the API and write the cache file once, then exit.
|
|
394
|
+
|
|
395
|
+
A one-shot equivalent of a single daemon tick — use it to force
|
|
396
|
+
~/.claude/usage-limits.json up to date without running the daemon.
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
api_data = fetch_usage()
|
|
400
|
+
except Exception as e:
|
|
401
|
+
reason = "rate limited (HTTP 429) — try again shortly" if _is_429(e) else str(e)
|
|
402
|
+
print(f"Could not refresh usage: {reason}", file=sys.stderr)
|
|
403
|
+
sys.exit(1)
|
|
404
|
+
data = build_usage_json(api_data, get_plan())
|
|
405
|
+
write_usage_file(data)
|
|
406
|
+
pcts = " ".join(f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data))
|
|
407
|
+
print(f"Updated {USAGE_FILE}")
|
|
408
|
+
if pcts:
|
|
409
|
+
print(f" {pcts}")
|
|
410
|
+
|
|
223
411
|
|
|
224
412
|
def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
225
413
|
"""Run in foreground, refresh every `interval` seconds."""
|
|
@@ -238,11 +426,7 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
|
238
426
|
data = build_usage_json(api_data, plan)
|
|
239
427
|
write_usage_file(data)
|
|
240
428
|
backoff = 0
|
|
241
|
-
pcts = []
|
|
242
|
-
for key in ("5h", "7d", "7d_sonnet"):
|
|
243
|
-
b = data.get(key)
|
|
244
|
-
if b:
|
|
245
|
-
pcts.append(f"{key}:{int(b['pct'])}%")
|
|
429
|
+
pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
|
|
246
430
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
|
|
247
431
|
except urllib.error.HTTPError as e:
|
|
248
432
|
if e.code == 429:
|
|
@@ -328,17 +512,13 @@ def cmd_statusline():
|
|
|
328
512
|
|
|
329
513
|
plan = usage.get("plan", "?")
|
|
330
514
|
five_h = usage.get("5h", {})
|
|
331
|
-
seven_d = usage.get("7d", {})
|
|
332
|
-
sonnet = usage.get("7d_sonnet", {})
|
|
333
515
|
|
|
334
516
|
parts = [f"{D}{pwd}{RST}", f"[{C}{model}{RST}]"]
|
|
335
517
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
parts.append(f"
|
|
340
|
-
if sonnet:
|
|
341
|
-
parts.append(f"son:{color_pct(int(sonnet.get('pct', 0)))}")
|
|
518
|
+
# Auto-include every quota bucket present (a newly added one just appears).
|
|
519
|
+
for key, bucket in _quota_buckets(usage):
|
|
520
|
+
abbrev = _bucket_display(key)[1]
|
|
521
|
+
parts.append(f"{abbrev}:{color_pct(int(bucket.get('pct', 0)))}")
|
|
342
522
|
|
|
343
523
|
parts.append(f"| {cost_fmt} | {D}{plan}{RST}")
|
|
344
524
|
|
|
@@ -375,6 +555,7 @@ def main():
|
|
|
375
555
|
sub = parser.add_subparsers(dest="command")
|
|
376
556
|
sub.add_parser("status", help="Show current usage (default)")
|
|
377
557
|
sub.add_parser("json", help="Print raw JSON")
|
|
558
|
+
sub.add_parser("refresh", help="Fetch fresh usage and update the cache file once")
|
|
378
559
|
daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
|
|
379
560
|
daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
|
|
380
561
|
help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
|
|
@@ -387,6 +568,8 @@ def main():
|
|
|
387
568
|
cmd_status()
|
|
388
569
|
elif cmd == "json":
|
|
389
570
|
cmd_status(raw_json=True)
|
|
571
|
+
elif cmd == "refresh":
|
|
572
|
+
cmd_refresh()
|
|
390
573
|
elif cmd == "daemon":
|
|
391
574
|
cmd_daemon(interval=args.interval)
|
|
392
575
|
elif cmd == "statusline":
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
import urllib.error
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
import ccusage
|
|
13
|
+
|
|
14
|
+
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _creds(expires_at: int) -> dict:
|
|
18
|
+
return {
|
|
19
|
+
"claudeAiOauth": {
|
|
20
|
+
"accessToken": "old-token",
|
|
21
|
+
"refreshToken": "old-refresh",
|
|
22
|
+
"expiresAt": expires_at,
|
|
23
|
+
"scopes": ["user:inference"],
|
|
24
|
+
"subscriptionType": "max",
|
|
25
|
+
},
|
|
26
|
+
"otherTopLevel": "keep-me",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FakeResponse(io.BytesIO):
|
|
31
|
+
def __enter__(self):
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def __exit__(self, *args):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _json_response(payload: dict) -> _FakeResponse:
|
|
39
|
+
return _FakeResponse(json.dumps(payload).encode())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
REFRESH_RESULT = {
|
|
43
|
+
"access_token": "new-token",
|
|
44
|
+
"refresh_token": "new-refresh",
|
|
45
|
+
"expires_in": 28800,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FetchUsageTests(unittest.TestCase):
|
|
50
|
+
def setUp(self):
|
|
51
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
52
|
+
self.addCleanup(self._tmp.cleanup)
|
|
53
|
+
self.credfile = Path(self._tmp.name) / ".credentials.json"
|
|
54
|
+
patcher = mock.patch.object(ccusage, "CREDENTIALS_FILE", self.credfile)
|
|
55
|
+
patcher.start()
|
|
56
|
+
self.addCleanup(patcher.stop)
|
|
57
|
+
|
|
58
|
+
def _write_creds(self, expires_at: int):
|
|
59
|
+
self.credfile.write_text(json.dumps(_creds(expires_at)))
|
|
60
|
+
|
|
61
|
+
def test_valid_token_skips_refresh(self):
|
|
62
|
+
self._write_creds(int(time.time() * 1000) + 3_600_000)
|
|
63
|
+
calls = []
|
|
64
|
+
|
|
65
|
+
def fake_urlopen(req, timeout=None):
|
|
66
|
+
calls.append(req.full_url)
|
|
67
|
+
self.assertEqual(req.headers["Authorization"], "Bearer old-token")
|
|
68
|
+
return _json_response({"five_hour": {"utilization": 4.0}})
|
|
69
|
+
|
|
70
|
+
with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
|
|
71
|
+
data = ccusage.fetch_usage()
|
|
72
|
+
|
|
73
|
+
self.assertEqual(data, {"five_hour": {"utilization": 4.0}})
|
|
74
|
+
self.assertEqual(calls, [USAGE_URL])
|
|
75
|
+
|
|
76
|
+
def test_expired_token_refreshes_and_persists_rotated_credentials(self):
|
|
77
|
+
self._write_creds(0)
|
|
78
|
+
calls = []
|
|
79
|
+
|
|
80
|
+
def fake_urlopen(req, timeout=None):
|
|
81
|
+
calls.append(req.full_url)
|
|
82
|
+
if req.full_url == ccusage.TOKEN_URL:
|
|
83
|
+
self.assertEqual(
|
|
84
|
+
json.loads(req.data),
|
|
85
|
+
{
|
|
86
|
+
"grant_type": "refresh_token",
|
|
87
|
+
"refresh_token": "old-refresh",
|
|
88
|
+
"client_id": ccusage.CLIENT_ID,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
return _json_response(REFRESH_RESULT)
|
|
92
|
+
self.assertEqual(req.headers["Authorization"], "Bearer new-token")
|
|
93
|
+
return _json_response({"five_hour": {"utilization": 4.0}})
|
|
94
|
+
|
|
95
|
+
with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
|
|
96
|
+
ccusage.fetch_usage()
|
|
97
|
+
|
|
98
|
+
self.assertEqual(calls, [ccusage.TOKEN_URL, USAGE_URL])
|
|
99
|
+
|
|
100
|
+
on_disk = json.loads(self.credfile.read_text())
|
|
101
|
+
oauth = on_disk["claudeAiOauth"]
|
|
102
|
+
self.assertEqual(oauth["accessToken"], "new-token")
|
|
103
|
+
self.assertEqual(oauth["refreshToken"], "new-refresh")
|
|
104
|
+
self.assertGreater(oauth["expiresAt"], time.time() * 1000)
|
|
105
|
+
# Fields not returned by the token endpoint must survive the rewrite
|
|
106
|
+
self.assertEqual(oauth["scopes"], ["user:inference"])
|
|
107
|
+
self.assertEqual(oauth["subscriptionType"], "max")
|
|
108
|
+
self.assertEqual(on_disk["otherTopLevel"], "keep-me")
|
|
109
|
+
self.assertEqual(self.credfile.stat().st_mode & 0o777, 0o600)
|
|
110
|
+
|
|
111
|
+
def test_rejected_token_retries_once_after_refresh(self):
|
|
112
|
+
self._write_creds(int(time.time() * 1000) + 3_600_000)
|
|
113
|
+
state = {"rejected": False}
|
|
114
|
+
|
|
115
|
+
def fake_urlopen(req, timeout=None):
|
|
116
|
+
if req.full_url == ccusage.TOKEN_URL:
|
|
117
|
+
return _json_response(REFRESH_RESULT)
|
|
118
|
+
if not state["rejected"]:
|
|
119
|
+
state["rejected"] = True
|
|
120
|
+
raise urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, io.BytesIO(b""))
|
|
121
|
+
self.assertEqual(req.headers["Authorization"], "Bearer new-token")
|
|
122
|
+
return _json_response({"ok": True})
|
|
123
|
+
|
|
124
|
+
with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
|
|
125
|
+
self.assertEqual(ccusage.fetch_usage(), {"ok": True})
|
|
126
|
+
|
|
127
|
+
on_disk = json.loads(self.credfile.read_text())
|
|
128
|
+
self.assertEqual(on_disk["claudeAiOauth"]["accessToken"], "new-token")
|
|
129
|
+
|
|
130
|
+
def test_persistent_rejection_raises(self):
|
|
131
|
+
self._write_creds(int(time.time() * 1000) + 3_600_000)
|
|
132
|
+
|
|
133
|
+
def fake_urlopen(req, timeout=None):
|
|
134
|
+
if req.full_url == ccusage.TOKEN_URL:
|
|
135
|
+
return _json_response(REFRESH_RESULT)
|
|
136
|
+
raise urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, io.BytesIO(b""))
|
|
137
|
+
|
|
138
|
+
with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
|
|
139
|
+
with self.assertRaises(urllib.error.HTTPError):
|
|
140
|
+
ccusage.fetch_usage()
|
|
141
|
+
|
|
142
|
+
def test_expired_token_without_refresh_token_raises(self):
|
|
143
|
+
creds = _creds(0)
|
|
144
|
+
del creds["claudeAiOauth"]["refreshToken"]
|
|
145
|
+
self.credfile.write_text(json.dumps(creds))
|
|
146
|
+
|
|
147
|
+
with self.assertRaisesRegex(RuntimeError, "no refresh token"):
|
|
148
|
+
ccusage.fetch_usage()
|
|
149
|
+
|
|
150
|
+
def test_refresh_endpoint_error_raises_runtime_error(self):
|
|
151
|
+
self._write_creds(0)
|
|
152
|
+
|
|
153
|
+
def fake_urlopen(req, timeout=None):
|
|
154
|
+
raise urllib.error.HTTPError(req.full_url, 429, "Too Many Requests", {}, io.BytesIO(b""))
|
|
155
|
+
|
|
156
|
+
with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
|
|
157
|
+
with self.assertRaisesRegex(RuntimeError, "Token refresh failed \\(429\\)"):
|
|
158
|
+
ccusage.fetch_usage()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class BuildUsageJsonTests(unittest.TestCase):
|
|
162
|
+
def test_reads_structured_limits_array(self):
|
|
163
|
+
# Current API shape: session/weekly totals and the model-scoped Fable
|
|
164
|
+
# quota all live in `limits`; the flat seven_day_* keys are ignored.
|
|
165
|
+
api_data = {
|
|
166
|
+
"seven_day_opus": None,
|
|
167
|
+
"seven_day_sonnet": None,
|
|
168
|
+
"iguana_necktie": None,
|
|
169
|
+
"limits": [
|
|
170
|
+
{"kind": "session", "percent": 3, "resets_at": "2026-07-04T05:10:00+00:00"},
|
|
171
|
+
{"kind": "weekly_all", "percent": 33, "resets_at": "2026-07-07T13:00:00+00:00"},
|
|
172
|
+
{"kind": "weekly_scoped", "percent": 58,
|
|
173
|
+
"resets_at": "2026-07-07T13:00:00+00:00",
|
|
174
|
+
"scope": {"model": {"id": None, "display_name": "Fable"}}},
|
|
175
|
+
],
|
|
176
|
+
"extra_usage": {"is_enabled": True, "monthly_limit": 100000},
|
|
177
|
+
}
|
|
178
|
+
result = ccusage.build_usage_json(api_data, "max_20x")
|
|
179
|
+
self.assertEqual(result["plan"], "max_20x")
|
|
180
|
+
self.assertEqual(result["5h"], {"pct": 3, "resets_at": "2026-07-04T05:10:00+00:00"})
|
|
181
|
+
self.assertEqual(result["7d"], {"pct": 33, "resets_at": "2026-07-07T13:00:00+00:00"})
|
|
182
|
+
self.assertEqual(result["7d_fable"], {"pct": 58, "resets_at": "2026-07-07T13:00:00+00:00"})
|
|
183
|
+
self.assertEqual(result["extra_usage"], {"is_enabled": True, "monthly_limit": 100000})
|
|
184
|
+
self.assertEqual(ccusage._bucket_display("7d_fable"), ("Week (Fable)", "fab"))
|
|
185
|
+
# Buckets appear in a stable, sensible order.
|
|
186
|
+
keys = [k for k, _ in ccusage._quota_buckets(result)]
|
|
187
|
+
self.assertEqual(keys, ["5h", "7d", "7d_fable"])
|
|
188
|
+
|
|
189
|
+
def test_scoped_limit_without_percent_is_skipped(self):
|
|
190
|
+
api_data = {"limits": [
|
|
191
|
+
{"kind": "weekly_scoped", "percent": None,
|
|
192
|
+
"scope": {"model": {"display_name": "Opus"}}},
|
|
193
|
+
]}
|
|
194
|
+
result = ccusage.build_usage_json(api_data, "max_20x")
|
|
195
|
+
self.assertEqual([k for k, _ in ccusage._quota_buckets(result)], [])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|