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.
- {ccusage-0.1.8 → ccusage-0.1.10}/.github/workflows/publish.yml +4 -2
- {ccusage-0.1.8 → ccusage-0.1.10}/PKG-INFO +6 -3
- {ccusage-0.1.8 → ccusage-0.1.10}/README.md +5 -2
- {ccusage-0.1.8 → ccusage-0.1.10}/pyproject.toml +1 -1
- {ccusage-0.1.8 → ccusage-0.1.10}/src/ccusage/__init__.py +177 -23
- ccusage-0.1.10/tests/test_ccusage.py +395 -0
- {ccusage-0.1.8 → ccusage-0.1.10}/uv.lock +1 -1
- ccusage-0.1.8/tests/test_ccusage.py +0 -199
- {ccusage-0.1.8 → ccusage-0.1.10}/.gitignore +0 -0
- {ccusage-0.1.8 → ccusage-0.1.10}/.python-version +0 -0
- {ccusage-0.1.8 → 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
|
|
@@ -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
|
|
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]
|
|
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
|
|
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]
|
|
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)",
|
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 = {
|
|
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 = "
|
|
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(
|
|
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:
|
|
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()
|
|
@@ -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
|
|
File without changes
|