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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccusage
3
- Version: 0.1.6
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. Claude Code refreshes it automatically as long as you have an active Claude Code session, the token stays valid for ccusage to read.
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. Claude Code refreshes it automatically as long as you have an active Claude Code session, the token stays valid for ccusage to read.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.1.6"
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"
@@ -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
- Requires a valid (non-expired) OAuth token from ~/.claude/.credentials.json.
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
- expires_at = oauth.get("expiresAt", 0)
119
- if time.time() * 1000 > expires_at:
120
- raise RuntimeError("OAuth token expired open Claude Code to refresh it")
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
- req = urllib.request.Request(
123
- "https://api.anthropic.com/api/oauth/usage",
124
- headers={
125
- "Authorization": f"Bearer {token}",
126
- "Content-Type": "application/json",
127
- "User-Agent": "claude-code/2.1.71",
128
- "anthropic-beta": "oauth-2025-04-20",
129
- },
130
- )
131
- with urllib.request.urlopen(req, timeout=10) as resp:
132
- return json.loads(resp.read())
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
- for key, api_key in [
143
- ("5h", "five_hour"),
144
- ("7d", "seven_day"),
145
- ("7d_sonnet", "seven_day_sonnet"),
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
- api_data = fetch_usage()
170
- plan = get_plan()
171
- 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
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 label, key in [
206
- ("Session (5h)", "5h"),
207
- ("Week (all)", "7d"),
208
- ("Week (Sonnet)", "7d_sonnet"),
209
- ("Week (Opus)", "7d_opus"),
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
- if five_h:
337
- parts.append(f"5h:{color_pct(int(five_h.get('pct', 0)))}")
338
- if seven_d:
339
- parts.append(f"7d:{color_pct(int(seven_d.get('pct', 0)))}")
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()
@@ -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