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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccusage
3
- Version: 0.1.7
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
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  description = "Claude Code usage monitor — fetches rate limits from Anthropic's API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- for key, api_key in [
208
- ("5h", "five_hour"),
209
- ("7d", "seven_day"),
210
- ("7d_sonnet", "seven_day_sonnet"),
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
- api_data = fetch_usage()
235
- plan = get_plan()
236
- data = build_usage_json(api_data, plan)
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 label, key in [
271
- ("Session (5h)", "5h"),
272
- ("Week (all)", "7d"),
273
- ("Week (Sonnet)", "7d_sonnet"),
274
- ("Week (Opus)", "7d_opus"),
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
- if five_h:
402
- parts.append(f"5h:{color_pct(int(five_h.get('pct', 0)))}")
403
- if seven_d:
404
- parts.append(f"7d:{color_pct(int(seven_d.get('pct', 0)))}")
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 test_maps_buckets_and_extra_usage(self):
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": 35.0, "resets_at": "2026-06-12T00:00:00+00:00"})
173
- self.assertEqual(result["7d"], {"pct": 14.0, "resets_at": None})
174
- self.assertNotIn("7d_sonnet", result)
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__":
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "ccusage"
7
- version = "0.1.4"
7
+ version = "0.1.8"
8
8
  source = { editable = "." }
File without changes
File without changes
File without changes