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.
- {ccusage-0.1.9 → ccusage-0.1.10}/PKG-INFO +1 -1
- {ccusage-0.1.9 → ccusage-0.1.10}/pyproject.toml +1 -1
- {ccusage-0.1.9 → ccusage-0.1.10}/src/ccusage/__init__.py +102 -2
- {ccusage-0.1.9 → ccusage-0.1.10}/tests/test_ccusage.py +124 -0
- {ccusage-0.1.9 → ccusage-0.1.10}/uv.lock +1 -1
- {ccusage-0.1.9 → ccusage-0.1.10}/.github/workflows/publish.yml +0 -0
- {ccusage-0.1.9 → ccusage-0.1.10}/.gitignore +0 -0
- {ccusage-0.1.9 → ccusage-0.1.10}/.python-version +0 -0
- {ccusage-0.1.9 → ccusage-0.1.10}/README.md +0 -0
- {ccusage-0.1.9 → ccusage-0.1.10}/src/ccusage/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ccusage
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.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
|
|
@@ -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
|
-
|
|
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 = {
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|