ccusage 0.1.7__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.7 → ccusage-0.1.8}/PKG-INFO +10 -1
- {ccusage-0.1.7 → ccusage-0.1.8}/README.md +9 -0
- {ccusage-0.1.7 → ccusage-0.1.8}/pyproject.toml +1 -1
- {ccusage-0.1.7 → ccusage-0.1.8}/src/ccusage/__init__.py +160 -42
- {ccusage-0.1.7 → ccusage-0.1.8}/tests/test_ccusage.py +27 -7
- {ccusage-0.1.7 → ccusage-0.1.8}/uv.lock +1 -1
- {ccusage-0.1.7 → ccusage-0.1.8}/.github/workflows/publish.yml +0 -0
- {ccusage-0.1.7 → ccusage-0.1.8}/.gitignore +0 -0
- {ccusage-0.1.7 → ccusage-0.1.8}/.python-version +0 -0
- {ccusage-0.1.7 → 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
|
```
|
|
@@ -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
|
```
|
|
@@ -197,25 +197,92 @@ def fetch_usage() -> dict:
|
|
|
197
197
|
raise RuntimeError("Failed to fetch usage after token refresh")
|
|
198
198
|
|
|
199
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
|
|
235
|
+
|
|
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
|
|
267
|
+
|
|
268
|
+
|
|
200
269
|
def build_usage_json(api_data: dict, plan: str) -> dict:
|
|
201
|
-
"""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
|
+
"""
|
|
202
277
|
result = {
|
|
203
278
|
"plan": plan,
|
|
204
279
|
"source": "api",
|
|
205
280
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
206
281
|
}
|
|
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
|
-
}
|
|
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
|
|
219
286
|
extra = api_data.get("extra_usage")
|
|
220
287
|
if extra:
|
|
221
288
|
result["extra_usage"] = extra
|
|
@@ -227,13 +294,51 @@ def write_usage_file(data: dict):
|
|
|
227
294
|
USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
228
295
|
|
|
229
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
|
+
|
|
230
323
|
# -- CLI commands --
|
|
231
324
|
|
|
232
325
|
def cmd_status(raw_json=False):
|
|
233
|
-
"""Fetch and display current usage.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
237
342
|
|
|
238
343
|
if raw_json:
|
|
239
344
|
print(json.dumps(data, indent=2))
|
|
@@ -266,18 +371,12 @@ def cmd_status(raw_json=False):
|
|
|
266
371
|
except Exception:
|
|
267
372
|
return ""
|
|
268
373
|
|
|
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}")
|
|
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}")
|
|
281
380
|
|
|
282
381
|
extra = data.get("extra_usage")
|
|
283
382
|
if extra and extra.get("is_enabled"):
|
|
@@ -285,6 +384,30 @@ def cmd_status(raw_json=False):
|
|
|
285
384
|
limit = extra.get("monthly_limit", 0) / 100
|
|
286
385
|
print(f" {'Extra usage':20s} ${used:.2f} / ${limit:.2f}")
|
|
287
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
|
+
|
|
288
411
|
|
|
289
412
|
def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
290
413
|
"""Run in foreground, refresh every `interval` seconds."""
|
|
@@ -303,11 +426,7 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
|
303
426
|
data = build_usage_json(api_data, plan)
|
|
304
427
|
write_usage_file(data)
|
|
305
428
|
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'])}%")
|
|
429
|
+
pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
|
|
311
430
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
|
|
312
431
|
except urllib.error.HTTPError as e:
|
|
313
432
|
if e.code == 429:
|
|
@@ -393,17 +512,13 @@ def cmd_statusline():
|
|
|
393
512
|
|
|
394
513
|
plan = usage.get("plan", "?")
|
|
395
514
|
five_h = usage.get("5h", {})
|
|
396
|
-
seven_d = usage.get("7d", {})
|
|
397
|
-
sonnet = usage.get("7d_sonnet", {})
|
|
398
515
|
|
|
399
516
|
parts = [f"{D}{pwd}{RST}", f"[{C}{model}{RST}]"]
|
|
400
517
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
parts.append(f"
|
|
405
|
-
if sonnet:
|
|
406
|
-
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)))}")
|
|
407
522
|
|
|
408
523
|
parts.append(f"| {cost_fmt} | {D}{plan}{RST}")
|
|
409
524
|
|
|
@@ -440,6 +555,7 @@ def main():
|
|
|
440
555
|
sub = parser.add_subparsers(dest="command")
|
|
441
556
|
sub.add_parser("status", help="Show current usage (default)")
|
|
442
557
|
sub.add_parser("json", help="Print raw JSON")
|
|
558
|
+
sub.add_parser("refresh", help="Fetch fresh usage and update the cache file once")
|
|
443
559
|
daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
|
|
444
560
|
daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
|
|
445
561
|
help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
|
|
@@ -452,6 +568,8 @@ def main():
|
|
|
452
568
|
cmd_status()
|
|
453
569
|
elif cmd == "json":
|
|
454
570
|
cmd_status(raw_json=True)
|
|
571
|
+
elif cmd == "refresh":
|
|
572
|
+
cmd_refresh()
|
|
455
573
|
elif cmd == "daemon":
|
|
456
574
|
cmd_daemon(interval=args.interval)
|
|
457
575
|
elif cmd == "statusline":
|
|
@@ -159,20 +159,40 @@ class FetchUsageTests(unittest.TestCase):
|
|
|
159
159
|
|
|
160
160
|
|
|
161
161
|
class BuildUsageJsonTests(unittest.TestCase):
|
|
162
|
-
def
|
|
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.
|
|
163
165
|
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
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
|
+
],
|
|
168
176
|
"extra_usage": {"is_enabled": True, "monthly_limit": 100000},
|
|
169
177
|
}
|
|
170
178
|
result = ccusage.build_usage_json(api_data, "max_20x")
|
|
171
179
|
self.assertEqual(result["plan"], "max_20x")
|
|
172
|
-
self.assertEqual(result["5h"], {"pct":
|
|
173
|
-
self.assertEqual(result["7d"], {"pct":
|
|
174
|
-
self.
|
|
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"})
|
|
175
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)], [])
|
|
176
196
|
|
|
177
197
|
|
|
178
198
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|