ccusage 0.1.8__tar.gz → 0.1.10__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,8 +1,10 @@
1
1
  name: Publish to PyPI
2
2
 
3
3
  on:
4
- release:
5
- types: [published]
4
+ workflow_dispatch:
5
+ push:
6
+ tags:
7
+ - "v*"
6
8
 
7
9
  permissions:
8
10
  id-token: write
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccusage
3
- Version: 0.1.8
3
+ Version: 0.1.10
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 (5h) 39% resets 1h26m
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] 5h:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
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
@@ -98,6 +98,9 @@ The flat `seven_day_*` keys above are all `null` now — the live quota data
98
98
  lives in a structured `limits` array, which ccusage reads. Each entry is
99
99
  self-describing: `kind` (`session` / `weekly_all` / `weekly_scoped`),
100
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.
101
104
  Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
102
105
  keyed by their model name, so any new one is auto-detected and displayed with
103
106
  no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
@@ -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 (5h) 39% resets 1h26m
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] 5h:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
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
@@ -89,6 +89,9 @@ The flat `seven_day_*` keys above are all `null` now — the live quota data
89
89
  lives in a structured `limits` array, which ccusage reads. Each entry is
90
90
  self-describing: `kind` (`session` / `weekly_all` / `weekly_scoped`),
91
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.
92
95
  Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
93
96
  keyed by their model name, so any new one is auto-detected and displayed with
94
97
  no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.1.8"
3
+ version = "0.1.10"
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"
@@ -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
@@ -71,10 +72,40 @@ CLAUDE_DIR = Path.home() / ".claude"
71
72
  CREDENTIALS_FILE = _resolve_claude_path(".credentials.json")
72
73
  USAGE_FILE = _resolve_claude_path("usage-limits.json")
73
74
  DAEMON_INTERVAL = 300 # 5 minutes
75
+ UNAVAILABLE_RETRY_INTERVAL = 3600 # 1 hour
74
76
  TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
75
77
  CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Claude Code's public OAuth client
76
78
 
77
79
 
80
+ class UsageUnavailableError(RuntimeError):
81
+ """The account is authenticated but its usage endpoint is unavailable."""
82
+
83
+ def __init__(self, code: str, message: str):
84
+ super().__init__(message)
85
+ self.code = code
86
+ self.message = message
87
+
88
+
89
+ def _usage_unavailable_error(error: urllib.error.HTTPError) -> UsageUnavailableError | None:
90
+ """Translate known account-level authorization failures into a typed error."""
91
+ if error.code != 403:
92
+ return None
93
+ try:
94
+ payload = json.loads(error.read())
95
+ api_error = payload.get("error") or {}
96
+ details = api_error.get("details") or {}
97
+ code = details.get("error_code")
98
+ message = api_error.get("message")
99
+ except (AttributeError, json.JSONDecodeError, TypeError):
100
+ return None
101
+ if code != "oauth_not_allowed_for_organization":
102
+ return None
103
+ return UsageUnavailableError(
104
+ code,
105
+ message or "OAuth authentication is not allowed for this organization.",
106
+ )
107
+
108
+
78
109
  def get_credentials() -> dict | None:
79
110
  """Read OAuth credentials from Claude Code's credentials file."""
80
111
  try:
@@ -94,6 +125,48 @@ def get_plan(creds: dict | None = None) -> str:
94
125
  return tier.removeprefix("default_claude_")
95
126
 
96
127
 
128
+ def _credential_identity(creds: dict | None) -> tuple[object, object]:
129
+ oauth = (creds or {}).get("claudeAiOauth", {})
130
+ return oauth.get("accessToken"), oauth.get("refreshToken")
131
+
132
+
133
+ def _persist_credentials(updated: dict, expected_identity: tuple[object, object]) -> dict:
134
+ latest = get_credentials()
135
+ if latest and _credential_identity(latest) != expected_identity:
136
+ return latest
137
+ if latest:
138
+ latest_oauth = latest.get("claudeAiOauth", {})
139
+ updated_oauth = updated.get("claudeAiOauth", {})
140
+ updated = {
141
+ **latest,
142
+ **updated,
143
+ "claudeAiOauth": {**latest_oauth, **updated_oauth},
144
+ }
145
+
146
+ tmp: Path | None = None
147
+ try:
148
+ CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
149
+ fd, tmp_name = tempfile.mkstemp(
150
+ prefix=f".{CREDENTIALS_FILE.name}.", dir=CREDENTIALS_FILE.parent
151
+ )
152
+ tmp = Path(tmp_name)
153
+ os.fchmod(fd, 0o600)
154
+ with os.fdopen(fd, "w") as file:
155
+ file.write(json.dumps(updated))
156
+ os.replace(tmp, CREDENTIALS_FILE)
157
+ except OSError as exc:
158
+ if tmp:
159
+ try:
160
+ tmp.unlink()
161
+ except OSError:
162
+ pass
163
+ print(
164
+ f"Warning: refreshed token but could not write {CREDENTIALS_FILE}: {exc}",
165
+ file=sys.stderr,
166
+ )
167
+ return updated
168
+
169
+
97
170
  def refresh_credentials(creds: dict) -> dict:
98
171
  """Refresh the OAuth access token and persist updated credentials.
99
172
 
@@ -101,6 +174,11 @@ def refresh_credentials(creds: dict) -> dict:
101
174
  back to .credentials.json or Claude Code's stored one goes stale and the
102
175
  user gets logged out.
103
176
  """
177
+ expected_identity = _credential_identity(creds)
178
+ latest = get_credentials()
179
+ if latest and _credential_identity(latest) != expected_identity:
180
+ return latest
181
+
104
182
  oauth = creds.get("claudeAiOauth", {})
105
183
  refresh_token = oauth.get("refreshToken")
106
184
  if not refresh_token:
@@ -120,6 +198,13 @@ def refresh_credentials(creds: dict) -> dict:
120
198
  with urllib.request.urlopen(req, timeout=10) as resp:
121
199
  result = json.loads(resp.read())
122
200
  except urllib.error.HTTPError as e:
201
+ unavailable = _usage_unavailable_error(e)
202
+ if unavailable:
203
+ raise unavailable from e
204
+ latest = get_credentials()
205
+ if e.code in (400, 401) and latest:
206
+ if _credential_identity(latest) != expected_identity:
207
+ return latest
123
208
  raise RuntimeError(f"Token refresh failed ({e.code}) — open Claude Code to refresh it") from e
124
209
 
125
210
  oauth = dict(oauth)
@@ -130,16 +215,7 @@ def refresh_credentials(creds: dict) -> dict:
130
215
  updated = dict(creds)
131
216
  updated["claudeAiOauth"] = oauth
132
217
 
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
218
+ return _persist_credentials(updated, expected_identity)
143
219
 
144
220
 
145
221
  def fetch_usage() -> dict:
@@ -168,13 +244,13 @@ def fetch_usage() -> dict:
168
244
  if not token:
169
245
  raise RuntimeError("No OAuth access token in credentials")
170
246
 
171
- # Refresh proactively if expired (or about to), then retry once on 401/403
172
- # in case the token was revoked early.
247
+ # Refresh proactively if expired (or about to).
173
248
  if time.time() * 1000 > oauth.get("expiresAt", 0) - 60_000:
174
249
  creds = refresh_credentials(creds)
175
250
  token = creds["claudeAiOauth"]["accessToken"]
176
251
 
177
- for attempt in range(2):
252
+ refreshed_after_rejection = False
253
+ for attempt in range(3):
178
254
  req = urllib.request.Request(
179
255
  "https://api.anthropic.com/api/oauth/usage",
180
256
  headers={
@@ -188,17 +264,32 @@ def fetch_usage() -> dict:
188
264
  with urllib.request.urlopen(req, timeout=10) as resp:
189
265
  return json.loads(resp.read())
190
266
  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:
267
+ unavailable = _usage_unavailable_error(e)
268
+ if unavailable:
269
+ raise unavailable from e
270
+ if e.code != 401:
195
271
  raise
196
272
 
273
+ latest = get_credentials()
274
+ latest_token = (latest or {}).get("claudeAiOauth", {}).get("accessToken")
275
+ if latest_token and latest_token != token:
276
+ creds, token = latest, latest_token
277
+ continue
278
+ if not refreshed_after_rejection:
279
+ creds = refresh_credentials(latest or creds)
280
+ token = creds["claudeAiOauth"]["accessToken"]
281
+ refreshed_after_rejection = True
282
+ continue
283
+ raise
284
+
197
285
  raise RuntimeError("Failed to fetch usage after token refresh")
198
286
 
199
287
 
200
288
  # Top-level keys in the cached usage dict that are NOT rate-limit buckets.
201
- _META_KEYS = {"plan", "source", "updated_at", "extra_usage"}
289
+ _META_KEYS = {
290
+ "plan", "source", "updated_at", "last_success_at", "status",
291
+ "unavailable", "extra_usage",
292
+ }
202
293
 
203
294
 
204
295
  def _bucket_display(short_key: str) -> tuple[str, str]:
@@ -208,6 +299,7 @@ def _bucket_display(short_key: str) -> tuple[str, str]:
208
299
  label automatically, e.g. "7d_fable" -> ("Week (Fable)", "fab").
209
300
  """
210
301
  known = {
302
+ "session": ("Session", "sess"),
211
303
  "5h": ("Session (5h)", "5h"),
212
304
  "7d": ("Week (all)", "7d"),
213
305
  "7d_opus": ("Week (Opus)", "opus"),
@@ -252,7 +344,7 @@ def _buckets_from_limits(limits) -> list:
252
344
  continue
253
345
  kind = entry.get("kind")
254
346
  if kind == "session":
255
- short_key, order = "5h", 0
347
+ short_key, order = "session", 0
256
348
  elif kind == "weekly_all":
257
349
  short_key, order = "7d", 1
258
350
  elif kind == "weekly_scoped":
@@ -289,6 +381,43 @@ def build_usage_json(api_data: dict, plan: str) -> dict:
289
381
  return result
290
382
 
291
383
 
384
+ def build_unavailable_usage(
385
+ error: UsageUnavailableError, plan: str, previous: dict | None = None
386
+ ) -> dict:
387
+ """Build a cache tombstone that replaces stale quota values."""
388
+ result = {
389
+ "plan": plan,
390
+ "source": "api",
391
+ "updated_at": datetime.now(timezone.utc).isoformat(),
392
+ "status": "unavailable",
393
+ "unavailable": {
394
+ "code": error.code,
395
+ "message": error.message,
396
+ "hint": "no active subscription or organization OAuth disabled",
397
+ },
398
+ }
399
+ if previous:
400
+ last_success = previous.get("last_success_at")
401
+ if not last_success and previous.get("status") != "unavailable":
402
+ last_success = previous.get("updated_at")
403
+ if last_success:
404
+ result["last_success_at"] = last_success
405
+ return result
406
+
407
+
408
+ def _unavailable_hint(data: dict) -> str | None:
409
+ unavailable = data.get("unavailable")
410
+ if data.get("status") != "unavailable" or not isinstance(unavailable, dict):
411
+ return None
412
+ return unavailable.get("hint") or unavailable.get("message") or "usage unavailable"
413
+
414
+
415
+ def _cache_unavailable(error: UsageUnavailableError) -> dict:
416
+ data = build_unavailable_usage(error, get_plan(), _read_cache())
417
+ write_usage_file(data)
418
+ return data
419
+
420
+
292
421
  def write_usage_file(data: dict):
293
422
  """Write usage data to ~/.claude/usage-limits.json."""
294
423
  USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
@@ -332,6 +461,8 @@ def cmd_status(raw_json=False):
332
461
  try:
333
462
  api_data = fetch_usage()
334
463
  data = build_usage_json(api_data, get_plan())
464
+ except UsageUnavailableError as e:
465
+ data = _cache_unavailable(e)
335
466
  except Exception as e:
336
467
  cached = _read_cache()
337
468
  if cached is None:
@@ -372,6 +503,10 @@ def cmd_status(raw_json=False):
372
503
  return ""
373
504
 
374
505
  print(f"Plan: {data.get('plan', '?')}")
506
+ unavailable = _unavailable_hint(data)
507
+ if unavailable:
508
+ print(f" Usage unavailable: {unavailable}")
509
+ return
375
510
  for key, bucket in _quota_buckets(data):
376
511
  label = _bucket_display(key)[0]
377
512
  pct = bucket["pct"]
@@ -397,6 +532,11 @@ def cmd_refresh():
397
532
  """
398
533
  try:
399
534
  api_data = fetch_usage()
535
+ except UsageUnavailableError as e:
536
+ data = _cache_unavailable(e)
537
+ print(f"Updated {USAGE_FILE}")
538
+ print(f" Usage unavailable: {_unavailable_hint(data)}")
539
+ return
400
540
  except Exception as e:
401
541
  reason = "rate limited (HTTP 429) — try again shortly" if _is_429(e) else str(e)
402
542
  print(f"Could not refresh usage: {reason}", file=sys.stderr)
@@ -428,6 +568,14 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
428
568
  backoff = 0
429
569
  pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
430
570
  print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
571
+ except UsageUnavailableError as e:
572
+ data = _cache_unavailable(e)
573
+ backoff = UNAVAILABLE_RETRY_INTERVAL
574
+ print(
575
+ f"[{datetime.now().strftime('%H:%M:%S')}] "
576
+ f"Usage unavailable: {_unavailable_hint(data)}; retrying in {backoff}s",
577
+ file=sys.stderr,
578
+ )
431
579
  except urllib.error.HTTPError as e:
432
580
  if e.code == 429:
433
581
  backoff = min((backoff or interval) * 2, 3600)
@@ -456,6 +604,8 @@ def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
456
604
  usage = build_usage_json(api_data, get_plan())
457
605
  write_usage_file(usage)
458
606
  return usage
607
+ except UsageUnavailableError as e:
608
+ return _cache_unavailable(e)
459
609
  except Exception:
460
610
  # Return whatever we had, even if stale
461
611
  try:
@@ -511,18 +661,22 @@ def cmd_statusline():
511
661
  usage = _get_cached_usage()
512
662
 
513
663
  plan = usage.get("plan", "?")
514
- five_h = usage.get("5h", {})
515
-
516
664
  parts = [f"{D}{pwd}{RST}", f"[{C}{model}{RST}]"]
517
665
 
666
+ if _unavailable_hint(usage):
667
+ parts.append("usage:unavailable")
668
+
518
669
  # Auto-include every quota bucket present (a newly added one just appears).
670
+ session_bucket = {}
519
671
  for key, bucket in _quota_buckets(usage):
520
672
  abbrev = _bucket_display(key)[1]
521
673
  parts.append(f"{abbrev}:{color_pct(int(bucket.get('pct', 0)))}")
674
+ if key in {"session", "5h"}:
675
+ session_bucket = bucket
522
676
 
523
677
  parts.append(f"| {cost_fmt} | {D}{plan}{RST}")
524
678
 
525
- reset = fmt_reset(five_h.get("resets_at"))
679
+ reset = fmt_reset(session_bucket.get("resets_at"))
526
680
  if reset:
527
681
  parts.append(f"| {D}reset:{reset}{RST}")
528
682
 
@@ -546,7 +700,7 @@ def cmd_install():
546
700
  }
547
701
 
548
702
  3. The statusline reads ~/.claude/usage-limits.json (written by the daemon)
549
- and shows: 5h session, 7d all-models, 7d Sonnet-specific limits.
703
+ and shows: session, weekly all-models, and weekly scoped limits.
550
704
  """)
551
705
 
552
706
 
@@ -0,0 +1,395 @@
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
+ self.assertEqual(list(self.credfile.parent.glob("..credentials.json.*")), [])
111
+
112
+ def test_rejected_token_retries_once_after_refresh(self):
113
+ self._write_creds(int(time.time() * 1000) + 3_600_000)
114
+ state = {"rejected": False}
115
+
116
+ def fake_urlopen(req, timeout=None):
117
+ if req.full_url == ccusage.TOKEN_URL:
118
+ return _json_response(REFRESH_RESULT)
119
+ if not state["rejected"]:
120
+ state["rejected"] = True
121
+ raise urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, io.BytesIO(b""))
122
+ self.assertEqual(req.headers["Authorization"], "Bearer new-token")
123
+ return _json_response({"ok": True})
124
+
125
+ with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
126
+ self.assertEqual(ccusage.fetch_usage(), {"ok": True})
127
+
128
+ on_disk = json.loads(self.credfile.read_text())
129
+ self.assertEqual(on_disk["claudeAiOauth"]["accessToken"], "new-token")
130
+
131
+ def test_persistent_rejection_raises(self):
132
+ self._write_creds(int(time.time() * 1000) + 3_600_000)
133
+
134
+ def fake_urlopen(req, timeout=None):
135
+ if req.full_url == ccusage.TOKEN_URL:
136
+ return _json_response(REFRESH_RESULT)
137
+ raise urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, io.BytesIO(b""))
138
+
139
+ with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
140
+ with self.assertRaises(urllib.error.HTTPError):
141
+ ccusage.fetch_usage()
142
+
143
+ def test_organization_oauth_denial_is_not_treated_as_token_rejection(self):
144
+ self._write_creds(int(time.time() * 1000) + 3_600_000)
145
+ calls = []
146
+ denial = {
147
+ "type": "error",
148
+ "error": {
149
+ "type": "permission_error",
150
+ "message": "OAuth authentication is currently not allowed for this organization.",
151
+ "details": {
152
+ "error_code": "oauth_not_allowed_for_organization",
153
+ },
154
+ },
155
+ }
156
+
157
+ def fake_urlopen(req, timeout=None):
158
+ calls.append(req.full_url)
159
+ raise urllib.error.HTTPError(
160
+ req.full_url, 403, "Forbidden", {},
161
+ io.BytesIO(json.dumps(denial).encode()),
162
+ )
163
+
164
+ with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
165
+ with self.assertRaises(ccusage.UsageUnavailableError) as raised:
166
+ ccusage.fetch_usage()
167
+
168
+ self.assertEqual(
169
+ raised.exception.code, "oauth_not_allowed_for_organization"
170
+ )
171
+ self.assertEqual(calls, [USAGE_URL])
172
+
173
+ def test_rejected_token_reloads_concurrently_updated_credentials(self):
174
+ self._write_creds(int(time.time() * 1000) + 3_600_000)
175
+ replacement = _creds(int(time.time() * 1000) + 3_600_000)
176
+ replacement["claudeAiOauth"]["accessToken"] = "replacement-token"
177
+ replacement["claudeAiOauth"]["refreshToken"] = "replacement-refresh"
178
+ calls = []
179
+
180
+ def fake_urlopen(req, timeout=None):
181
+ calls.append(req.full_url)
182
+ if len(calls) == 1:
183
+ self.credfile.write_text(json.dumps(replacement))
184
+ raise urllib.error.HTTPError(
185
+ req.full_url, 401, "Unauthorized", {}, io.BytesIO(b"")
186
+ )
187
+ self.assertEqual(
188
+ req.headers["Authorization"], "Bearer replacement-token"
189
+ )
190
+ return _json_response({"ok": True})
191
+
192
+ with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
193
+ self.assertEqual(ccusage.fetch_usage(), {"ok": True})
194
+
195
+ self.assertEqual(calls, [USAGE_URL, USAGE_URL])
196
+
197
+ def test_refresh_does_not_overwrite_concurrently_rotated_credentials(self):
198
+ self._write_creds(0)
199
+ original = json.loads(self.credfile.read_text())
200
+ replacement = _creds(int(time.time() * 1000) + 3_600_000)
201
+ replacement["claudeAiOauth"]["accessToken"] = "replacement-token"
202
+ replacement["claudeAiOauth"]["refreshToken"] = "replacement-refresh"
203
+
204
+ class RotatingResponse(_FakeResponse):
205
+ def read(inner_self, *args):
206
+ self.credfile.write_text(json.dumps(replacement))
207
+ return super().read(*args)
208
+
209
+ with mock.patch.object(
210
+ ccusage.urllib.request,
211
+ "urlopen",
212
+ return_value=RotatingResponse(json.dumps(REFRESH_RESULT).encode()),
213
+ ):
214
+ result = ccusage.refresh_credentials(original)
215
+
216
+ self.assertEqual(result, replacement)
217
+ self.assertEqual(json.loads(self.credfile.read_text()), replacement)
218
+
219
+ def test_expired_token_without_refresh_token_raises(self):
220
+ creds = _creds(0)
221
+ del creds["claudeAiOauth"]["refreshToken"]
222
+ self.credfile.write_text(json.dumps(creds))
223
+
224
+ with self.assertRaisesRegex(RuntimeError, "no refresh token"):
225
+ ccusage.fetch_usage()
226
+
227
+ def test_refresh_endpoint_error_raises_runtime_error(self):
228
+ self._write_creds(0)
229
+
230
+ def fake_urlopen(req, timeout=None):
231
+ raise urllib.error.HTTPError(req.full_url, 429, "Too Many Requests", {}, io.BytesIO(b""))
232
+
233
+ with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
234
+ with self.assertRaisesRegex(RuntimeError, "Token refresh failed \\(429\\)"):
235
+ ccusage.fetch_usage()
236
+
237
+
238
+ class BuildUsageJsonTests(unittest.TestCase):
239
+ def test_reads_structured_limits_array(self):
240
+ # Current API shape: session/weekly totals and the model-scoped Fable
241
+ # quota all live in `limits`; the flat seven_day_* keys are ignored.
242
+ api_data = {
243
+ "seven_day_opus": None,
244
+ "seven_day_sonnet": None,
245
+ "iguana_necktie": None,
246
+ "limits": [
247
+ {"kind": "session", "percent": 3, "resets_at": "2026-07-04T05:10:00+00:00"},
248
+ {"kind": "weekly_all", "percent": 33, "resets_at": "2026-07-07T13:00:00+00:00"},
249
+ {"kind": "weekly_scoped", "percent": 58,
250
+ "resets_at": "2026-07-07T13:00:00+00:00",
251
+ "scope": {"model": {"id": None, "display_name": "Fable"}}},
252
+ ],
253
+ "extra_usage": {"is_enabled": True, "monthly_limit": 100000},
254
+ }
255
+ result = ccusage.build_usage_json(api_data, "max_20x")
256
+ self.assertEqual(result["plan"], "max_20x")
257
+ self.assertEqual(result["session"], {"pct": 3, "resets_at": "2026-07-04T05:10:00+00:00"})
258
+ self.assertNotIn("5h", result)
259
+ self.assertEqual(result["7d"], {"pct": 33, "resets_at": "2026-07-07T13:00:00+00:00"})
260
+ self.assertEqual(result["7d_fable"], {"pct": 58, "resets_at": "2026-07-07T13:00:00+00:00"})
261
+ self.assertEqual(result["extra_usage"], {"is_enabled": True, "monthly_limit": 100000})
262
+ self.assertEqual(ccusage._bucket_display("7d_fable"), ("Week (Fable)", "fab"))
263
+ # Buckets appear in a stable, sensible order.
264
+ keys = [k for k, _ in ccusage._quota_buckets(result)]
265
+ self.assertEqual(keys, ["session", "7d", "7d_fable"])
266
+
267
+ def test_statusline_uses_semantic_session_bucket_for_reset(self):
268
+ usage = {
269
+ "plan": "max_20x",
270
+ "session": {
271
+ "pct": 3,
272
+ "resets_at": "2099-01-01T00:00:00+00:00",
273
+ },
274
+ "7d": {"pct": 13, "resets_at": None},
275
+ }
276
+ status_input = {
277
+ "model": {"display_name": "Test"},
278
+ "workspace": {"current_dir": "/code/test"},
279
+ }
280
+ with (
281
+ mock.patch.object(ccusage, "_get_cached_usage", return_value=usage),
282
+ mock.patch("sys.stdin", io.StringIO(json.dumps(status_input))),
283
+ mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
284
+ ):
285
+ ccusage.cmd_statusline()
286
+
287
+ output = stdout.getvalue()
288
+ self.assertIn("sess:3%", output)
289
+ self.assertIn("reset:", output)
290
+
291
+ def test_scoped_limit_without_percent_is_skipped(self):
292
+ api_data = {"limits": [
293
+ {"kind": "weekly_scoped", "percent": None,
294
+ "scope": {"model": {"display_name": "Opus"}}},
295
+ ]}
296
+ result = ccusage.build_usage_json(api_data, "max_20x")
297
+ self.assertEqual([k for k, _ in ccusage._quota_buckets(result)], [])
298
+
299
+ def test_unavailable_tombstone_preserves_success_time_not_quota_values(self):
300
+ previous = {
301
+ "plan": "max_20x",
302
+ "updated_at": "2026-07-27T12:00:00+00:00",
303
+ "session": {"pct": 3, "resets_at": None},
304
+ "7d": {"pct": 13, "resets_at": None},
305
+ }
306
+ error = ccusage.UsageUnavailableError(
307
+ "oauth_not_allowed_for_organization",
308
+ "OAuth authentication is currently not allowed for this organization.",
309
+ )
310
+
311
+ result = ccusage.build_unavailable_usage(error, "max_20x", previous)
312
+
313
+ self.assertEqual(result["status"], "unavailable")
314
+ self.assertEqual(result["last_success_at"], previous["updated_at"])
315
+ self.assertEqual(
316
+ result["unavailable"]["code"],
317
+ "oauth_not_allowed_for_organization",
318
+ )
319
+ self.assertNotIn("session", result)
320
+ self.assertNotIn("7d", result)
321
+ self.assertEqual(list(ccusage._quota_buckets(result)), [])
322
+
323
+
324
+ class UnavailableCacheTests(unittest.TestCase):
325
+ def setUp(self):
326
+ self._tmp = tempfile.TemporaryDirectory()
327
+ self.addCleanup(self._tmp.cleanup)
328
+ self.usage_file = Path(self._tmp.name) / "usage-limits.json"
329
+ patcher = mock.patch.object(ccusage, "USAGE_FILE", self.usage_file)
330
+ patcher.start()
331
+ self.addCleanup(patcher.stop)
332
+ self.error = ccusage.UsageUnavailableError(
333
+ "oauth_not_allowed_for_organization",
334
+ "OAuth authentication is currently not allowed for this organization.",
335
+ )
336
+
337
+ def test_stale_cache_is_replaced_when_refresh_is_unavailable(self):
338
+ self.usage_file.write_text(json.dumps({
339
+ "plan": "max_20x",
340
+ "updated_at": "2026-07-27T12:00:00+00:00",
341
+ "session": {"pct": 3, "resets_at": None},
342
+ }))
343
+
344
+ with (
345
+ mock.patch.object(ccusage, "fetch_usage", side_effect=self.error),
346
+ mock.patch.object(ccusage, "get_plan", return_value="max_20x"),
347
+ ):
348
+ result = ccusage._get_cached_usage(max_age=0)
349
+
350
+ self.assertEqual(result["status"], "unavailable")
351
+ self.assertNotIn("session", result)
352
+ self.assertEqual(json.loads(self.usage_file.read_text()), result)
353
+
354
+ def test_daemon_uses_hourly_retry_for_unavailable_usage(self):
355
+ sleeps = []
356
+
357
+ def stop_after_sleep(seconds):
358
+ sleeps.append(seconds)
359
+ raise KeyboardInterrupt
360
+
361
+ with (
362
+ mock.patch.object(ccusage, "fetch_usage", side_effect=self.error),
363
+ mock.patch.object(ccusage, "get_plan", return_value="max_20x"),
364
+ mock.patch.object(ccusage.signal, "signal"),
365
+ mock.patch.object(ccusage.time, "sleep", side_effect=stop_after_sleep),
366
+ mock.patch("sys.stdout", new_callable=io.StringIO),
367
+ mock.patch("sys.stderr", new_callable=io.StringIO),
368
+ self.assertRaises(KeyboardInterrupt),
369
+ ):
370
+ ccusage.cmd_daemon(interval=300)
371
+
372
+ self.assertEqual(sleeps, [ccusage.UNAVAILABLE_RETRY_INTERVAL])
373
+ cached = json.loads(self.usage_file.read_text())
374
+ self.assertEqual(cached["status"], "unavailable")
375
+
376
+ def test_statusline_reports_unavailable_without_quota_values(self):
377
+ usage = ccusage.build_unavailable_usage(self.error, "max_20x")
378
+ status_input = {
379
+ "model": {"display_name": "Test"},
380
+ "workspace": {"current_dir": "/code/test"},
381
+ }
382
+ with (
383
+ mock.patch.object(ccusage, "_get_cached_usage", return_value=usage),
384
+ mock.patch("sys.stdin", io.StringIO(json.dumps(status_input))),
385
+ mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
386
+ ):
387
+ ccusage.cmd_statusline()
388
+
389
+ output = stdout.getvalue()
390
+ self.assertIn("usage:unavailable", output)
391
+ self.assertNotIn("sess:", output)
392
+
393
+
394
+ if __name__ == "__main__":
395
+ unittest.main()
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "ccusage"
7
- version = "0.1.8"
7
+ version = "0.1.10"
8
8
  source = { editable = "." }
@@ -1,199 +0,0 @@
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