ccusage 0.1.7__tar.gz → 0.1.9__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.7 → ccusage-0.1.9}/.github/workflows/publish.yml +4 -2
- {ccusage-0.1.7 → ccusage-0.1.9}/PKG-INFO +15 -3
- {ccusage-0.1.7 → ccusage-0.1.9}/README.md +14 -2
- {ccusage-0.1.7 → ccusage-0.1.9}/pyproject.toml +1 -1
- {ccusage-0.1.7 → ccusage-0.1.9}/src/ccusage/__init__.py +235 -63
- {ccusage-0.1.7 → ccusage-0.1.9}/tests/test_ccusage.py +99 -7
- {ccusage-0.1.7 → ccusage-0.1.9}/uv.lock +1 -1
- {ccusage-0.1.7 → ccusage-0.1.9}/.gitignore +0 -0
- {ccusage-0.1.7 → ccusage-0.1.9}/.python-version +0 -0
- {ccusage-0.1.7 → ccusage-0.1.9}/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.9
|
|
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
|
|
@@ -17,7 +17,7 @@ Claude Code usage monitor. Fetches your real rate limit data from Anthropic's AP
|
|
|
17
17
|
|
|
18
18
|
```
|
|
19
19
|
Plan: max_5x
|
|
20
|
-
Session
|
|
20
|
+
Session 39% resets 1h26m
|
|
21
21
|
Week (all) 15% resets 143h26m
|
|
22
22
|
Week (Sonnet) 39% resets 65h26m
|
|
23
23
|
Extra usage $0.00 / $1000.00
|
|
@@ -26,7 +26,7 @@ Plan: max_5x
|
|
|
26
26
|
Claude Code statusline (self-caching — refreshes from API when stale, no daemon needed):
|
|
27
27
|
|
|
28
28
|
```
|
|
29
|
-
~/projects/myapp [Opus 4.6]
|
|
29
|
+
~/projects/myapp [Opus 4.6] sess:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Install
|
|
@@ -94,6 +94,18 @@ 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
|
+
Because the structured response does not include a session duration, ccusage
|
|
102
|
+
labels that limit `Session` and caches it under `session` rather than assuming
|
|
103
|
+
it is always five hours. Existing `5h` cache files remain readable.
|
|
104
|
+
Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
|
|
105
|
+
keyed by their model name, so any new one is auto-detected and displayed with
|
|
106
|
+
no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
|
|
107
|
+
statusline `fab:`).
|
|
108
|
+
|
|
97
109
|
### API response format
|
|
98
110
|
|
|
99
111
|
```
|
|
@@ -8,7 +8,7 @@ Claude Code usage monitor. Fetches your real rate limit data from Anthropic's AP
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
Plan: max_5x
|
|
11
|
-
Session
|
|
11
|
+
Session 39% resets 1h26m
|
|
12
12
|
Week (all) 15% resets 143h26m
|
|
13
13
|
Week (Sonnet) 39% resets 65h26m
|
|
14
14
|
Extra usage $0.00 / $1000.00
|
|
@@ -17,7 +17,7 @@ Plan: max_5x
|
|
|
17
17
|
Claude Code statusline (self-caching — refreshes from API when stale, no daemon needed):
|
|
18
18
|
|
|
19
19
|
```
|
|
20
|
-
~/projects/myapp [Opus 4.6]
|
|
20
|
+
~/projects/myapp [Opus 4.6] sess:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## Install
|
|
@@ -85,6 +85,18 @@ 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
|
+
Because the structured response does not include a session duration, ccusage
|
|
93
|
+
labels that limit `Session` and caches it under `session` rather than assuming
|
|
94
|
+
it is always five hours. Existing `5h` cache files remain readable.
|
|
95
|
+
Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
|
|
96
|
+
keyed by their model name, so any new one is auto-detected and displayed with
|
|
97
|
+
no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
|
|
98
|
+
statusline `fab:`).
|
|
99
|
+
|
|
88
100
|
### API response format
|
|
89
101
|
|
|
90
102
|
```
|
|
@@ -19,6 +19,7 @@ import os
|
|
|
19
19
|
import signal
|
|
20
20
|
import subprocess
|
|
21
21
|
import sys
|
|
22
|
+
import tempfile
|
|
22
23
|
import time
|
|
23
24
|
import urllib.error
|
|
24
25
|
import urllib.request
|
|
@@ -94,6 +95,48 @@ def get_plan(creds: dict | None = None) -> str:
|
|
|
94
95
|
return tier.removeprefix("default_claude_")
|
|
95
96
|
|
|
96
97
|
|
|
98
|
+
def _credential_identity(creds: dict | None) -> tuple[object, object]:
|
|
99
|
+
oauth = (creds or {}).get("claudeAiOauth", {})
|
|
100
|
+
return oauth.get("accessToken"), oauth.get("refreshToken")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _persist_credentials(updated: dict, expected_identity: tuple[object, object]) -> dict:
|
|
104
|
+
latest = get_credentials()
|
|
105
|
+
if latest and _credential_identity(latest) != expected_identity:
|
|
106
|
+
return latest
|
|
107
|
+
if latest:
|
|
108
|
+
latest_oauth = latest.get("claudeAiOauth", {})
|
|
109
|
+
updated_oauth = updated.get("claudeAiOauth", {})
|
|
110
|
+
updated = {
|
|
111
|
+
**latest,
|
|
112
|
+
**updated,
|
|
113
|
+
"claudeAiOauth": {**latest_oauth, **updated_oauth},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
tmp: Path | None = None
|
|
117
|
+
try:
|
|
118
|
+
CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
120
|
+
prefix=f".{CREDENTIALS_FILE.name}.", dir=CREDENTIALS_FILE.parent
|
|
121
|
+
)
|
|
122
|
+
tmp = Path(tmp_name)
|
|
123
|
+
os.fchmod(fd, 0o600)
|
|
124
|
+
with os.fdopen(fd, "w") as file:
|
|
125
|
+
file.write(json.dumps(updated))
|
|
126
|
+
os.replace(tmp, CREDENTIALS_FILE)
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
if tmp:
|
|
129
|
+
try:
|
|
130
|
+
tmp.unlink()
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
print(
|
|
134
|
+
f"Warning: refreshed token but could not write {CREDENTIALS_FILE}: {exc}",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
return updated
|
|
138
|
+
|
|
139
|
+
|
|
97
140
|
def refresh_credentials(creds: dict) -> dict:
|
|
98
141
|
"""Refresh the OAuth access token and persist updated credentials.
|
|
99
142
|
|
|
@@ -101,6 +144,11 @@ def refresh_credentials(creds: dict) -> dict:
|
|
|
101
144
|
back to .credentials.json or Claude Code's stored one goes stale and the
|
|
102
145
|
user gets logged out.
|
|
103
146
|
"""
|
|
147
|
+
expected_identity = _credential_identity(creds)
|
|
148
|
+
latest = get_credentials()
|
|
149
|
+
if latest and _credential_identity(latest) != expected_identity:
|
|
150
|
+
return latest
|
|
151
|
+
|
|
104
152
|
oauth = creds.get("claudeAiOauth", {})
|
|
105
153
|
refresh_token = oauth.get("refreshToken")
|
|
106
154
|
if not refresh_token:
|
|
@@ -120,6 +168,10 @@ def refresh_credentials(creds: dict) -> dict:
|
|
|
120
168
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
121
169
|
result = json.loads(resp.read())
|
|
122
170
|
except urllib.error.HTTPError as e:
|
|
171
|
+
latest = get_credentials()
|
|
172
|
+
if e.code in (400, 401) and latest:
|
|
173
|
+
if _credential_identity(latest) != expected_identity:
|
|
174
|
+
return latest
|
|
123
175
|
raise RuntimeError(f"Token refresh failed ({e.code}) — open Claude Code to refresh it") from e
|
|
124
176
|
|
|
125
177
|
oauth = dict(oauth)
|
|
@@ -130,16 +182,7 @@ def refresh_credentials(creds: dict) -> dict:
|
|
|
130
182
|
updated = dict(creds)
|
|
131
183
|
updated["claudeAiOauth"] = oauth
|
|
132
184
|
|
|
133
|
-
|
|
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
|
|
185
|
+
return _persist_credentials(updated, expected_identity)
|
|
143
186
|
|
|
144
187
|
|
|
145
188
|
def fetch_usage() -> dict:
|
|
@@ -168,13 +211,13 @@ def fetch_usage() -> dict:
|
|
|
168
211
|
if not token:
|
|
169
212
|
raise RuntimeError("No OAuth access token in credentials")
|
|
170
213
|
|
|
171
|
-
# Refresh proactively if expired (or about to)
|
|
172
|
-
# in case the token was revoked early.
|
|
214
|
+
# Refresh proactively if expired (or about to).
|
|
173
215
|
if time.time() * 1000 > oauth.get("expiresAt", 0) - 60_000:
|
|
174
216
|
creds = refresh_credentials(creds)
|
|
175
217
|
token = creds["claudeAiOauth"]["accessToken"]
|
|
176
218
|
|
|
177
|
-
|
|
219
|
+
refreshed_after_rejection = False
|
|
220
|
+
for attempt in range(3):
|
|
178
221
|
req = urllib.request.Request(
|
|
179
222
|
"https://api.anthropic.com/api/oauth/usage",
|
|
180
223
|
headers={
|
|
@@ -188,34 +231,111 @@ def fetch_usage() -> dict:
|
|
|
188
231
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
189
232
|
return json.loads(resp.read())
|
|
190
233
|
except urllib.error.HTTPError as e:
|
|
191
|
-
if e.code in (401, 403)
|
|
192
|
-
creds = refresh_credentials(creds)
|
|
193
|
-
token = creds["claudeAiOauth"]["accessToken"]
|
|
194
|
-
else:
|
|
234
|
+
if e.code not in (401, 403):
|
|
195
235
|
raise
|
|
196
236
|
|
|
237
|
+
latest = get_credentials()
|
|
238
|
+
latest_token = (latest or {}).get("claudeAiOauth", {}).get("accessToken")
|
|
239
|
+
if latest_token and latest_token != token:
|
|
240
|
+
creds, token = latest, latest_token
|
|
241
|
+
continue
|
|
242
|
+
if not refreshed_after_rejection:
|
|
243
|
+
creds = refresh_credentials(latest or creds)
|
|
244
|
+
token = creds["claudeAiOauth"]["accessToken"]
|
|
245
|
+
refreshed_after_rejection = True
|
|
246
|
+
continue
|
|
247
|
+
raise
|
|
248
|
+
|
|
197
249
|
raise RuntimeError("Failed to fetch usage after token refresh")
|
|
198
250
|
|
|
199
251
|
|
|
252
|
+
# Top-level keys in the cached usage dict that are NOT rate-limit buckets.
|
|
253
|
+
_META_KEYS = {"plan", "source", "updated_at", "extra_usage"}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _bucket_display(short_key: str) -> tuple[str, str]:
|
|
257
|
+
"""Return (full label, statusline abbrev) for a short bucket key.
|
|
258
|
+
|
|
259
|
+
Derived from the key so unknown/newly-added buckets get a reasonable
|
|
260
|
+
label automatically, e.g. "7d_fable" -> ("Week (Fable)", "fab").
|
|
261
|
+
"""
|
|
262
|
+
known = {
|
|
263
|
+
"session": ("Session", "sess"),
|
|
264
|
+
"5h": ("Session (5h)", "5h"),
|
|
265
|
+
"7d": ("Week (all)", "7d"),
|
|
266
|
+
"7d_opus": ("Week (Opus)", "opus"),
|
|
267
|
+
"7d_sonnet": ("Week (Sonnet)", "son"),
|
|
268
|
+
}
|
|
269
|
+
if short_key in known:
|
|
270
|
+
return known[short_key]
|
|
271
|
+
if short_key.startswith("7d_"):
|
|
272
|
+
model = short_key[3:]
|
|
273
|
+
return f"Week ({model.replace('_', ' ').title()})", model[:3]
|
|
274
|
+
return short_key.replace("_", " ").title(), short_key[:4]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _quota_buckets(data: dict):
|
|
278
|
+
"""Yield (short_key, bucket) for each rate-limit bucket in a usage dict.
|
|
279
|
+
|
|
280
|
+
Relies on insertion order (build_usage_json inserts them sorted), so
|
|
281
|
+
callers get a stable, sensible display sequence.
|
|
282
|
+
"""
|
|
283
|
+
for key, val in data.items():
|
|
284
|
+
if key in _META_KEYS:
|
|
285
|
+
continue
|
|
286
|
+
if isinstance(val, dict) and "pct" in val:
|
|
287
|
+
yield key, val
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _buckets_from_limits(limits) -> list:
|
|
291
|
+
"""Parse the API's structured `limits` array into (order, short_key, bucket).
|
|
292
|
+
|
|
293
|
+
This is the current API shape. Each entry is self-describing:
|
|
294
|
+
{"kind": "session"|"weekly_all"|"weekly_scoped", "percent": 58,
|
|
295
|
+
"resets_at": "...", "scope": {"model": {"display_name": "Fable"}}}
|
|
296
|
+
Model-scoped weekly limits (Opus, Sonnet, Fable, ...) are keyed by their
|
|
297
|
+
model name, so a newly added one appears automatically.
|
|
298
|
+
"""
|
|
299
|
+
out = []
|
|
300
|
+
for entry in limits:
|
|
301
|
+
if not isinstance(entry, dict):
|
|
302
|
+
continue
|
|
303
|
+
pct = entry.get("percent")
|
|
304
|
+
if pct is None:
|
|
305
|
+
continue
|
|
306
|
+
kind = entry.get("kind")
|
|
307
|
+
if kind == "session":
|
|
308
|
+
short_key, order = "session", 0
|
|
309
|
+
elif kind == "weekly_all":
|
|
310
|
+
short_key, order = "7d", 1
|
|
311
|
+
elif kind == "weekly_scoped":
|
|
312
|
+
model = (entry.get("scope") or {}).get("model") or {}
|
|
313
|
+
name = (model.get("display_name") or model.get("id") or "scoped").strip()
|
|
314
|
+
short_key = "7d_" + name.lower().replace(" ", "_")
|
|
315
|
+
order = {"7d_opus": 2, "7d_sonnet": 3}.get(short_key, 4)
|
|
316
|
+
else:
|
|
317
|
+
short_key, order = kind or "unknown", 5
|
|
318
|
+
out.append((order, short_key, {"pct": pct, "resets_at": entry.get("resets_at")}))
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
200
322
|
def build_usage_json(api_data: dict, plan: str) -> dict:
|
|
201
|
-
"""Transform API response into our cached format.
|
|
323
|
+
"""Transform API response into our cached format.
|
|
324
|
+
|
|
325
|
+
Reads the API's structured `limits` array — every quota it reports (session,
|
|
326
|
+
weekly-all, and per-model weekly windows like Opus/Sonnet/Fable) is included
|
|
327
|
+
and mapped to a short key, so a newly added one appears automatically
|
|
328
|
+
instead of being dropped.
|
|
329
|
+
"""
|
|
202
330
|
result = {
|
|
203
331
|
"plan": plan,
|
|
204
332
|
"source": "api",
|
|
205
333
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
206
334
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
("7d_opus", "seven_day_opus"),
|
|
212
|
-
]:
|
|
213
|
-
bucket = api_data.get(api_key)
|
|
214
|
-
if bucket:
|
|
215
|
-
result[key] = {
|
|
216
|
-
"pct": bucket["utilization"],
|
|
217
|
-
"resets_at": bucket.get("resets_at"),
|
|
218
|
-
}
|
|
335
|
+
limits = api_data.get("limits")
|
|
336
|
+
buckets = _buckets_from_limits(limits) if isinstance(limits, list) else []
|
|
337
|
+
for _, short_key, bucket in sorted(buckets, key=lambda b: b[0]):
|
|
338
|
+
result[short_key] = bucket
|
|
219
339
|
extra = api_data.get("extra_usage")
|
|
220
340
|
if extra:
|
|
221
341
|
result["extra_usage"] = extra
|
|
@@ -227,13 +347,51 @@ def write_usage_file(data: dict):
|
|
|
227
347
|
USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
228
348
|
|
|
229
349
|
|
|
350
|
+
def _read_cache() -> dict | None:
|
|
351
|
+
"""Read the cached usage file, or None if missing/unreadable."""
|
|
352
|
+
try:
|
|
353
|
+
return json.loads(USAGE_FILE.read_text())
|
|
354
|
+
except Exception:
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _is_429(err: Exception) -> bool:
|
|
359
|
+
"""True if the exception is (or wraps) an HTTP 429 rate-limit error."""
|
|
360
|
+
if isinstance(err, urllib.error.HTTPError) and err.code == 429:
|
|
361
|
+
return True
|
|
362
|
+
return "429" in str(err)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _cache_age_str(data: dict) -> str:
|
|
366
|
+
"""Return e.g. ' 3m ago' from a usage dict's updated_at, or '' if unknown."""
|
|
367
|
+
try:
|
|
368
|
+
updated = datetime.fromisoformat(data["updated_at"])
|
|
369
|
+
secs = int((datetime.now(timezone.utc) - updated).total_seconds())
|
|
370
|
+
m = secs // 60
|
|
371
|
+
return f" {m // 60}h{m % 60}m ago" if m >= 60 else f" {m}m ago"
|
|
372
|
+
except Exception:
|
|
373
|
+
return ""
|
|
374
|
+
|
|
375
|
+
|
|
230
376
|
# -- CLI commands --
|
|
231
377
|
|
|
232
378
|
def cmd_status(raw_json=False):
|
|
233
|
-
"""Fetch and display current usage.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
379
|
+
"""Fetch and display current usage.
|
|
380
|
+
|
|
381
|
+
Fetches fresh from the API, but on failure (rate limit, offline, expired
|
|
382
|
+
token) falls back to the last cached usage rather than crashing.
|
|
383
|
+
"""
|
|
384
|
+
stale = False
|
|
385
|
+
try:
|
|
386
|
+
api_data = fetch_usage()
|
|
387
|
+
data = build_usage_json(api_data, get_plan())
|
|
388
|
+
except Exception as e:
|
|
389
|
+
cached = _read_cache()
|
|
390
|
+
if cached is None:
|
|
391
|
+
reason = "rate limited (HTTP 429) — try again shortly" if _is_429(e) else str(e)
|
|
392
|
+
print(f"Could not fetch usage: {reason}", file=sys.stderr)
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
data, stale = cached, True
|
|
237
395
|
|
|
238
396
|
if raw_json:
|
|
239
397
|
print(json.dumps(data, indent=2))
|
|
@@ -266,18 +424,12 @@ def cmd_status(raw_json=False):
|
|
|
266
424
|
except Exception:
|
|
267
425
|
return ""
|
|
268
426
|
|
|
269
|
-
print(f"Plan: {plan}")
|
|
270
|
-
for
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
("
|
|
275
|
-
]:
|
|
276
|
-
bucket = data.get(key)
|
|
277
|
-
if bucket:
|
|
278
|
-
pct = bucket["pct"]
|
|
279
|
-
reset = fmt_reset(bucket.get("resets_at"))
|
|
280
|
-
print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
|
|
427
|
+
print(f"Plan: {data.get('plan', '?')}")
|
|
428
|
+
for key, bucket in _quota_buckets(data):
|
|
429
|
+
label = _bucket_display(key)[0]
|
|
430
|
+
pct = bucket["pct"]
|
|
431
|
+
reset = fmt_reset(bucket.get("resets_at"))
|
|
432
|
+
print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
|
|
281
433
|
|
|
282
434
|
extra = data.get("extra_usage")
|
|
283
435
|
if extra and extra.get("is_enabled"):
|
|
@@ -285,6 +437,30 @@ def cmd_status(raw_json=False):
|
|
|
285
437
|
limit = extra.get("monthly_limit", 0) / 100
|
|
286
438
|
print(f" {'Extra usage':20s} ${used:.2f} / ${limit:.2f}")
|
|
287
439
|
|
|
440
|
+
if stale:
|
|
441
|
+
age = _cache_age_str(data)
|
|
442
|
+
print(f"{D} (cached{age} — live fetch failed){RST}", file=sys.stderr)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def cmd_refresh():
|
|
446
|
+
"""Fetch fresh usage from the API and write the cache file once, then exit.
|
|
447
|
+
|
|
448
|
+
A one-shot equivalent of a single daemon tick — use it to force
|
|
449
|
+
~/.claude/usage-limits.json up to date without running the daemon.
|
|
450
|
+
"""
|
|
451
|
+
try:
|
|
452
|
+
api_data = fetch_usage()
|
|
453
|
+
except Exception as e:
|
|
454
|
+
reason = "rate limited (HTTP 429) — try again shortly" if _is_429(e) else str(e)
|
|
455
|
+
print(f"Could not refresh usage: {reason}", file=sys.stderr)
|
|
456
|
+
sys.exit(1)
|
|
457
|
+
data = build_usage_json(api_data, get_plan())
|
|
458
|
+
write_usage_file(data)
|
|
459
|
+
pcts = " ".join(f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data))
|
|
460
|
+
print(f"Updated {USAGE_FILE}")
|
|
461
|
+
if pcts:
|
|
462
|
+
print(f" {pcts}")
|
|
463
|
+
|
|
288
464
|
|
|
289
465
|
def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
290
466
|
"""Run in foreground, refresh every `interval` seconds."""
|
|
@@ -303,11 +479,7 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
|
303
479
|
data = build_usage_json(api_data, plan)
|
|
304
480
|
write_usage_file(data)
|
|
305
481
|
backoff = 0
|
|
306
|
-
pcts = []
|
|
307
|
-
for key in ("5h", "7d", "7d_sonnet"):
|
|
308
|
-
b = data.get(key)
|
|
309
|
-
if b:
|
|
310
|
-
pcts.append(f"{key}:{int(b['pct'])}%")
|
|
482
|
+
pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
|
|
311
483
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
|
|
312
484
|
except urllib.error.HTTPError as e:
|
|
313
485
|
if e.code == 429:
|
|
@@ -392,22 +564,19 @@ def cmd_statusline():
|
|
|
392
564
|
usage = _get_cached_usage()
|
|
393
565
|
|
|
394
566
|
plan = usage.get("plan", "?")
|
|
395
|
-
five_h = usage.get("5h", {})
|
|
396
|
-
seven_d = usage.get("7d", {})
|
|
397
|
-
sonnet = usage.get("7d_sonnet", {})
|
|
398
|
-
|
|
399
567
|
parts = [f"{D}{pwd}{RST}", f"[{C}{model}{RST}]"]
|
|
400
568
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
569
|
+
# Auto-include every quota bucket present (a newly added one just appears).
|
|
570
|
+
session_bucket = {}
|
|
571
|
+
for key, bucket in _quota_buckets(usage):
|
|
572
|
+
abbrev = _bucket_display(key)[1]
|
|
573
|
+
parts.append(f"{abbrev}:{color_pct(int(bucket.get('pct', 0)))}")
|
|
574
|
+
if key in {"session", "5h"}:
|
|
575
|
+
session_bucket = bucket
|
|
407
576
|
|
|
408
577
|
parts.append(f"| {cost_fmt} | {D}{plan}{RST}")
|
|
409
578
|
|
|
410
|
-
reset = fmt_reset(
|
|
579
|
+
reset = fmt_reset(session_bucket.get("resets_at"))
|
|
411
580
|
if reset:
|
|
412
581
|
parts.append(f"| {D}reset:{reset}{RST}")
|
|
413
582
|
|
|
@@ -431,7 +600,7 @@ def cmd_install():
|
|
|
431
600
|
}
|
|
432
601
|
|
|
433
602
|
3. The statusline reads ~/.claude/usage-limits.json (written by the daemon)
|
|
434
|
-
and shows:
|
|
603
|
+
and shows: session, weekly all-models, and weekly scoped limits.
|
|
435
604
|
""")
|
|
436
605
|
|
|
437
606
|
|
|
@@ -440,6 +609,7 @@ def main():
|
|
|
440
609
|
sub = parser.add_subparsers(dest="command")
|
|
441
610
|
sub.add_parser("status", help="Show current usage (default)")
|
|
442
611
|
sub.add_parser("json", help="Print raw JSON")
|
|
612
|
+
sub.add_parser("refresh", help="Fetch fresh usage and update the cache file once")
|
|
443
613
|
daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
|
|
444
614
|
daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
|
|
445
615
|
help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
|
|
@@ -452,6 +622,8 @@ def main():
|
|
|
452
622
|
cmd_status()
|
|
453
623
|
elif cmd == "json":
|
|
454
624
|
cmd_status(raw_json=True)
|
|
625
|
+
elif cmd == "refresh":
|
|
626
|
+
cmd_refresh()
|
|
455
627
|
elif cmd == "daemon":
|
|
456
628
|
cmd_daemon(interval=args.interval)
|
|
457
629
|
elif cmd == "statusline":
|
|
@@ -107,6 +107,7 @@ class FetchUsageTests(unittest.TestCase):
|
|
|
107
107
|
self.assertEqual(oauth["subscriptionType"], "max")
|
|
108
108
|
self.assertEqual(on_disk["otherTopLevel"], "keep-me")
|
|
109
109
|
self.assertEqual(self.credfile.stat().st_mode & 0o777, 0o600)
|
|
110
|
+
self.assertEqual(list(self.credfile.parent.glob("..credentials.json.*")), [])
|
|
110
111
|
|
|
111
112
|
def test_rejected_token_retries_once_after_refresh(self):
|
|
112
113
|
self._write_creds(int(time.time() * 1000) + 3_600_000)
|
|
@@ -139,6 +140,52 @@ class FetchUsageTests(unittest.TestCase):
|
|
|
139
140
|
with self.assertRaises(urllib.error.HTTPError):
|
|
140
141
|
ccusage.fetch_usage()
|
|
141
142
|
|
|
143
|
+
def test_rejected_token_reloads_concurrently_updated_credentials(self):
|
|
144
|
+
self._write_creds(int(time.time() * 1000) + 3_600_000)
|
|
145
|
+
replacement = _creds(int(time.time() * 1000) + 3_600_000)
|
|
146
|
+
replacement["claudeAiOauth"]["accessToken"] = "replacement-token"
|
|
147
|
+
replacement["claudeAiOauth"]["refreshToken"] = "replacement-refresh"
|
|
148
|
+
calls = []
|
|
149
|
+
|
|
150
|
+
def fake_urlopen(req, timeout=None):
|
|
151
|
+
calls.append(req.full_url)
|
|
152
|
+
if len(calls) == 1:
|
|
153
|
+
self.credfile.write_text(json.dumps(replacement))
|
|
154
|
+
raise urllib.error.HTTPError(
|
|
155
|
+
req.full_url, 401, "Unauthorized", {}, io.BytesIO(b"")
|
|
156
|
+
)
|
|
157
|
+
self.assertEqual(
|
|
158
|
+
req.headers["Authorization"], "Bearer replacement-token"
|
|
159
|
+
)
|
|
160
|
+
return _json_response({"ok": True})
|
|
161
|
+
|
|
162
|
+
with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
|
|
163
|
+
self.assertEqual(ccusage.fetch_usage(), {"ok": True})
|
|
164
|
+
|
|
165
|
+
self.assertEqual(calls, [USAGE_URL, USAGE_URL])
|
|
166
|
+
|
|
167
|
+
def test_refresh_does_not_overwrite_concurrently_rotated_credentials(self):
|
|
168
|
+
self._write_creds(0)
|
|
169
|
+
original = json.loads(self.credfile.read_text())
|
|
170
|
+
replacement = _creds(int(time.time() * 1000) + 3_600_000)
|
|
171
|
+
replacement["claudeAiOauth"]["accessToken"] = "replacement-token"
|
|
172
|
+
replacement["claudeAiOauth"]["refreshToken"] = "replacement-refresh"
|
|
173
|
+
|
|
174
|
+
class RotatingResponse(_FakeResponse):
|
|
175
|
+
def read(inner_self, *args):
|
|
176
|
+
self.credfile.write_text(json.dumps(replacement))
|
|
177
|
+
return super().read(*args)
|
|
178
|
+
|
|
179
|
+
with mock.patch.object(
|
|
180
|
+
ccusage.urllib.request,
|
|
181
|
+
"urlopen",
|
|
182
|
+
return_value=RotatingResponse(json.dumps(REFRESH_RESULT).encode()),
|
|
183
|
+
):
|
|
184
|
+
result = ccusage.refresh_credentials(original)
|
|
185
|
+
|
|
186
|
+
self.assertEqual(result, replacement)
|
|
187
|
+
self.assertEqual(json.loads(self.credfile.read_text()), replacement)
|
|
188
|
+
|
|
142
189
|
def test_expired_token_without_refresh_token_raises(self):
|
|
143
190
|
creds = _creds(0)
|
|
144
191
|
del creds["claudeAiOauth"]["refreshToken"]
|
|
@@ -159,20 +206,65 @@ class FetchUsageTests(unittest.TestCase):
|
|
|
159
206
|
|
|
160
207
|
|
|
161
208
|
class BuildUsageJsonTests(unittest.TestCase):
|
|
162
|
-
def
|
|
209
|
+
def test_reads_structured_limits_array(self):
|
|
210
|
+
# Current API shape: session/weekly totals and the model-scoped Fable
|
|
211
|
+
# quota all live in `limits`; the flat seven_day_* keys are ignored.
|
|
163
212
|
api_data = {
|
|
164
|
-
"five_hour": {"utilization": 35.0, "resets_at": "2026-06-12T00:00:00+00:00"},
|
|
165
|
-
"seven_day": {"utilization": 14.0, "resets_at": None},
|
|
166
|
-
"seven_day_sonnet": None,
|
|
167
213
|
"seven_day_opus": None,
|
|
214
|
+
"seven_day_sonnet": None,
|
|
215
|
+
"iguana_necktie": None,
|
|
216
|
+
"limits": [
|
|
217
|
+
{"kind": "session", "percent": 3, "resets_at": "2026-07-04T05:10:00+00:00"},
|
|
218
|
+
{"kind": "weekly_all", "percent": 33, "resets_at": "2026-07-07T13:00:00+00:00"},
|
|
219
|
+
{"kind": "weekly_scoped", "percent": 58,
|
|
220
|
+
"resets_at": "2026-07-07T13:00:00+00:00",
|
|
221
|
+
"scope": {"model": {"id": None, "display_name": "Fable"}}},
|
|
222
|
+
],
|
|
168
223
|
"extra_usage": {"is_enabled": True, "monthly_limit": 100000},
|
|
169
224
|
}
|
|
170
225
|
result = ccusage.build_usage_json(api_data, "max_20x")
|
|
171
226
|
self.assertEqual(result["plan"], "max_20x")
|
|
172
|
-
self.assertEqual(result["
|
|
173
|
-
self.
|
|
174
|
-
self.
|
|
227
|
+
self.assertEqual(result["session"], {"pct": 3, "resets_at": "2026-07-04T05:10:00+00:00"})
|
|
228
|
+
self.assertNotIn("5h", result)
|
|
229
|
+
self.assertEqual(result["7d"], {"pct": 33, "resets_at": "2026-07-07T13:00:00+00:00"})
|
|
230
|
+
self.assertEqual(result["7d_fable"], {"pct": 58, "resets_at": "2026-07-07T13:00:00+00:00"})
|
|
175
231
|
self.assertEqual(result["extra_usage"], {"is_enabled": True, "monthly_limit": 100000})
|
|
232
|
+
self.assertEqual(ccusage._bucket_display("7d_fable"), ("Week (Fable)", "fab"))
|
|
233
|
+
# Buckets appear in a stable, sensible order.
|
|
234
|
+
keys = [k for k, _ in ccusage._quota_buckets(result)]
|
|
235
|
+
self.assertEqual(keys, ["session", "7d", "7d_fable"])
|
|
236
|
+
|
|
237
|
+
def test_statusline_uses_semantic_session_bucket_for_reset(self):
|
|
238
|
+
usage = {
|
|
239
|
+
"plan": "max_20x",
|
|
240
|
+
"session": {
|
|
241
|
+
"pct": 3,
|
|
242
|
+
"resets_at": "2099-01-01T00:00:00+00:00",
|
|
243
|
+
},
|
|
244
|
+
"7d": {"pct": 13, "resets_at": None},
|
|
245
|
+
}
|
|
246
|
+
status_input = {
|
|
247
|
+
"model": {"display_name": "Test"},
|
|
248
|
+
"workspace": {"current_dir": "/code/test"},
|
|
249
|
+
}
|
|
250
|
+
with (
|
|
251
|
+
mock.patch.object(ccusage, "_get_cached_usage", return_value=usage),
|
|
252
|
+
mock.patch("sys.stdin", io.StringIO(json.dumps(status_input))),
|
|
253
|
+
mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
|
|
254
|
+
):
|
|
255
|
+
ccusage.cmd_statusline()
|
|
256
|
+
|
|
257
|
+
output = stdout.getvalue()
|
|
258
|
+
self.assertIn("sess:3%", output)
|
|
259
|
+
self.assertIn("reset:", output)
|
|
260
|
+
|
|
261
|
+
def test_scoped_limit_without_percent_is_skipped(self):
|
|
262
|
+
api_data = {"limits": [
|
|
263
|
+
{"kind": "weekly_scoped", "percent": None,
|
|
264
|
+
"scope": {"model": {"display_name": "Opus"}}},
|
|
265
|
+
]}
|
|
266
|
+
result = ccusage.build_usage_json(api_data, "max_20x")
|
|
267
|
+
self.assertEqual([k for k, _ in ccusage._quota_buckets(result)], [])
|
|
176
268
|
|
|
177
269
|
|
|
178
270
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|