ccusage 0.1.9__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccusage
3
- Version: 0.1.9
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.1.9"
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"
@@ -72,10 +72,40 @@ CLAUDE_DIR = Path.home() / ".claude"
72
72
  CREDENTIALS_FILE = _resolve_claude_path(".credentials.json")
73
73
  USAGE_FILE = _resolve_claude_path("usage-limits.json")
74
74
  DAEMON_INTERVAL = 300 # 5 minutes
75
+ UNAVAILABLE_RETRY_INTERVAL = 3600 # 1 hour
75
76
  TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
76
77
  CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Claude Code's public OAuth client
77
78
 
78
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
+
79
109
  def get_credentials() -> dict | None:
80
110
  """Read OAuth credentials from Claude Code's credentials file."""
81
111
  try:
@@ -168,6 +198,9 @@ def refresh_credentials(creds: dict) -> dict:
168
198
  with urllib.request.urlopen(req, timeout=10) as resp:
169
199
  result = json.loads(resp.read())
170
200
  except urllib.error.HTTPError as e:
201
+ unavailable = _usage_unavailable_error(e)
202
+ if unavailable:
203
+ raise unavailable from e
171
204
  latest = get_credentials()
172
205
  if e.code in (400, 401) and latest:
173
206
  if _credential_identity(latest) != expected_identity:
@@ -231,7 +264,10 @@ def fetch_usage() -> dict:
231
264
  with urllib.request.urlopen(req, timeout=10) as resp:
232
265
  return json.loads(resp.read())
233
266
  except urllib.error.HTTPError as e:
234
- if e.code not in (401, 403):
267
+ unavailable = _usage_unavailable_error(e)
268
+ if unavailable:
269
+ raise unavailable from e
270
+ if e.code != 401:
235
271
  raise
236
272
 
237
273
  latest = get_credentials()
@@ -250,7 +286,10 @@ def fetch_usage() -> dict:
250
286
 
251
287
 
252
288
  # Top-level keys in the cached usage dict that are NOT rate-limit buckets.
253
- _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
+ }
254
293
 
255
294
 
256
295
  def _bucket_display(short_key: str) -> tuple[str, str]:
@@ -342,6 +381,43 @@ def build_usage_json(api_data: dict, plan: str) -> dict:
342
381
  return result
343
382
 
344
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
+
345
421
  def write_usage_file(data: dict):
346
422
  """Write usage data to ~/.claude/usage-limits.json."""
347
423
  USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
@@ -385,6 +461,8 @@ def cmd_status(raw_json=False):
385
461
  try:
386
462
  api_data = fetch_usage()
387
463
  data = build_usage_json(api_data, get_plan())
464
+ except UsageUnavailableError as e:
465
+ data = _cache_unavailable(e)
388
466
  except Exception as e:
389
467
  cached = _read_cache()
390
468
  if cached is None:
@@ -425,6 +503,10 @@ def cmd_status(raw_json=False):
425
503
  return ""
426
504
 
427
505
  print(f"Plan: {data.get('plan', '?')}")
506
+ unavailable = _unavailable_hint(data)
507
+ if unavailable:
508
+ print(f" Usage unavailable: {unavailable}")
509
+ return
428
510
  for key, bucket in _quota_buckets(data):
429
511
  label = _bucket_display(key)[0]
430
512
  pct = bucket["pct"]
@@ -450,6 +532,11 @@ def cmd_refresh():
450
532
  """
451
533
  try:
452
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
453
540
  except Exception as e:
454
541
  reason = "rate limited (HTTP 429) — try again shortly" if _is_429(e) else str(e)
455
542
  print(f"Could not refresh usage: {reason}", file=sys.stderr)
@@ -481,6 +568,14 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
481
568
  backoff = 0
482
569
  pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
483
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
+ )
484
579
  except urllib.error.HTTPError as e:
485
580
  if e.code == 429:
486
581
  backoff = min((backoff or interval) * 2, 3600)
@@ -509,6 +604,8 @@ def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
509
604
  usage = build_usage_json(api_data, get_plan())
510
605
  write_usage_file(usage)
511
606
  return usage
607
+ except UsageUnavailableError as e:
608
+ return _cache_unavailable(e)
512
609
  except Exception:
513
610
  # Return whatever we had, even if stale
514
611
  try:
@@ -566,6 +663,9 @@ def cmd_statusline():
566
663
  plan = usage.get("plan", "?")
567
664
  parts = [f"{D}{pwd}{RST}", f"[{C}{model}{RST}]"]
568
665
 
666
+ if _unavailable_hint(usage):
667
+ parts.append("usage:unavailable")
668
+
569
669
  # Auto-include every quota bucket present (a newly added one just appears).
570
670
  session_bucket = {}
571
671
  for key, bucket in _quota_buckets(usage):
@@ -140,6 +140,36 @@ class FetchUsageTests(unittest.TestCase):
140
140
  with self.assertRaises(urllib.error.HTTPError):
141
141
  ccusage.fetch_usage()
142
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
+
143
173
  def test_rejected_token_reloads_concurrently_updated_credentials(self):
144
174
  self._write_creds(int(time.time() * 1000) + 3_600_000)
145
175
  replacement = _creds(int(time.time() * 1000) + 3_600_000)
@@ -266,6 +296,100 @@ class BuildUsageJsonTests(unittest.TestCase):
266
296
  result = ccusage.build_usage_json(api_data, "max_20x")
267
297
  self.assertEqual([k for k, _ in ccusage._quota_buckets(result)], [])
268
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
+
269
393
 
270
394
  if __name__ == "__main__":
271
395
  unittest.main()
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "ccusage"
7
- version = "0.1.9"
7
+ version = "0.1.10"
8
8
  source = { editable = "." }
File without changes
File without changes
File without changes