ccusage 0.1.8__tar.gz → 0.1.9__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.9}/.github/workflows/publish.yml +4 -2
- {ccusage-0.1.8 → ccusage-0.1.9}/PKG-INFO +6 -3
- {ccusage-0.1.8 → ccusage-0.1.9}/README.md +5 -2
- {ccusage-0.1.8 → ccusage-0.1.9}/pyproject.toml +1 -1
- {ccusage-0.1.8 → ccusage-0.1.9}/src/ccusage/__init__.py +76 -22
- {ccusage-0.1.8 → ccusage-0.1.9}/tests/test_ccusage.py +74 -2
- {ccusage-0.1.8 → ccusage-0.1.9}/uv.lock +1 -1
- {ccusage-0.1.8 → ccusage-0.1.9}/.gitignore +0 -0
- {ccusage-0.1.8 → ccusage-0.1.9}/.python-version +0 -0
- {ccusage-0.1.8 → ccusage-0.1.9}/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.9
|
|
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
|
|
@@ -94,6 +95,48 @@ def get_plan(creds: dict | None = None) -> str:
|
|
|
94
95
|
return tier.removeprefix("default_claude_")
|
|
95
96
|
|
|
96
97
|
|
|
98
|
+
def _credential_identity(creds: dict | None) -> tuple[object, object]:
|
|
99
|
+
oauth = (creds or {}).get("claudeAiOauth", {})
|
|
100
|
+
return oauth.get("accessToken"), oauth.get("refreshToken")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _persist_credentials(updated: dict, expected_identity: tuple[object, object]) -> dict:
|
|
104
|
+
latest = get_credentials()
|
|
105
|
+
if latest and _credential_identity(latest) != expected_identity:
|
|
106
|
+
return latest
|
|
107
|
+
if latest:
|
|
108
|
+
latest_oauth = latest.get("claudeAiOauth", {})
|
|
109
|
+
updated_oauth = updated.get("claudeAiOauth", {})
|
|
110
|
+
updated = {
|
|
111
|
+
**latest,
|
|
112
|
+
**updated,
|
|
113
|
+
"claudeAiOauth": {**latest_oauth, **updated_oauth},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
tmp: Path | None = None
|
|
117
|
+
try:
|
|
118
|
+
CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
120
|
+
prefix=f".{CREDENTIALS_FILE.name}.", dir=CREDENTIALS_FILE.parent
|
|
121
|
+
)
|
|
122
|
+
tmp = Path(tmp_name)
|
|
123
|
+
os.fchmod(fd, 0o600)
|
|
124
|
+
with os.fdopen(fd, "w") as file:
|
|
125
|
+
file.write(json.dumps(updated))
|
|
126
|
+
os.replace(tmp, CREDENTIALS_FILE)
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
if tmp:
|
|
129
|
+
try:
|
|
130
|
+
tmp.unlink()
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
print(
|
|
134
|
+
f"Warning: refreshed token but could not write {CREDENTIALS_FILE}: {exc}",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
return updated
|
|
138
|
+
|
|
139
|
+
|
|
97
140
|
def refresh_credentials(creds: dict) -> dict:
|
|
98
141
|
"""Refresh the OAuth access token and persist updated credentials.
|
|
99
142
|
|
|
@@ -101,6 +144,11 @@ def refresh_credentials(creds: dict) -> dict:
|
|
|
101
144
|
back to .credentials.json or Claude Code's stored one goes stale and the
|
|
102
145
|
user gets logged out.
|
|
103
146
|
"""
|
|
147
|
+
expected_identity = _credential_identity(creds)
|
|
148
|
+
latest = get_credentials()
|
|
149
|
+
if latest and _credential_identity(latest) != expected_identity:
|
|
150
|
+
return latest
|
|
151
|
+
|
|
104
152
|
oauth = creds.get("claudeAiOauth", {})
|
|
105
153
|
refresh_token = oauth.get("refreshToken")
|
|
106
154
|
if not refresh_token:
|
|
@@ -120,6 +168,10 @@ def refresh_credentials(creds: dict) -> dict:
|
|
|
120
168
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
121
169
|
result = json.loads(resp.read())
|
|
122
170
|
except urllib.error.HTTPError as e:
|
|
171
|
+
latest = get_credentials()
|
|
172
|
+
if e.code in (400, 401) and latest:
|
|
173
|
+
if _credential_identity(latest) != expected_identity:
|
|
174
|
+
return latest
|
|
123
175
|
raise RuntimeError(f"Token refresh failed ({e.code}) — open Claude Code to refresh it") from e
|
|
124
176
|
|
|
125
177
|
oauth = dict(oauth)
|
|
@@ -130,16 +182,7 @@ def refresh_credentials(creds: dict) -> dict:
|
|
|
130
182
|
updated = dict(creds)
|
|
131
183
|
updated["claudeAiOauth"] = oauth
|
|
132
184
|
|
|
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
|
|
185
|
+
return _persist_credentials(updated, expected_identity)
|
|
143
186
|
|
|
144
187
|
|
|
145
188
|
def fetch_usage() -> dict:
|
|
@@ -168,13 +211,13 @@ def fetch_usage() -> dict:
|
|
|
168
211
|
if not token:
|
|
169
212
|
raise RuntimeError("No OAuth access token in credentials")
|
|
170
213
|
|
|
171
|
-
# Refresh proactively if expired (or about to)
|
|
172
|
-
# in case the token was revoked early.
|
|
214
|
+
# Refresh proactively if expired (or about to).
|
|
173
215
|
if time.time() * 1000 > oauth.get("expiresAt", 0) - 60_000:
|
|
174
216
|
creds = refresh_credentials(creds)
|
|
175
217
|
token = creds["claudeAiOauth"]["accessToken"]
|
|
176
218
|
|
|
177
|
-
|
|
219
|
+
refreshed_after_rejection = False
|
|
220
|
+
for attempt in range(3):
|
|
178
221
|
req = urllib.request.Request(
|
|
179
222
|
"https://api.anthropic.com/api/oauth/usage",
|
|
180
223
|
headers={
|
|
@@ -188,12 +231,21 @@ def fetch_usage() -> dict:
|
|
|
188
231
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
189
232
|
return json.loads(resp.read())
|
|
190
233
|
except urllib.error.HTTPError as e:
|
|
191
|
-
if e.code in (401, 403)
|
|
192
|
-
creds = refresh_credentials(creds)
|
|
193
|
-
token = creds["claudeAiOauth"]["accessToken"]
|
|
194
|
-
else:
|
|
234
|
+
if e.code not in (401, 403):
|
|
195
235
|
raise
|
|
196
236
|
|
|
237
|
+
latest = get_credentials()
|
|
238
|
+
latest_token = (latest or {}).get("claudeAiOauth", {}).get("accessToken")
|
|
239
|
+
if latest_token and latest_token != token:
|
|
240
|
+
creds, token = latest, latest_token
|
|
241
|
+
continue
|
|
242
|
+
if not refreshed_after_rejection:
|
|
243
|
+
creds = refresh_credentials(latest or creds)
|
|
244
|
+
token = creds["claudeAiOauth"]["accessToken"]
|
|
245
|
+
refreshed_after_rejection = True
|
|
246
|
+
continue
|
|
247
|
+
raise
|
|
248
|
+
|
|
197
249
|
raise RuntimeError("Failed to fetch usage after token refresh")
|
|
198
250
|
|
|
199
251
|
|
|
@@ -208,6 +260,7 @@ def _bucket_display(short_key: str) -> tuple[str, str]:
|
|
|
208
260
|
label automatically, e.g. "7d_fable" -> ("Week (Fable)", "fab").
|
|
209
261
|
"""
|
|
210
262
|
known = {
|
|
263
|
+
"session": ("Session", "sess"),
|
|
211
264
|
"5h": ("Session (5h)", "5h"),
|
|
212
265
|
"7d": ("Week (all)", "7d"),
|
|
213
266
|
"7d_opus": ("Week (Opus)", "opus"),
|
|
@@ -252,7 +305,7 @@ def _buckets_from_limits(limits) -> list:
|
|
|
252
305
|
continue
|
|
253
306
|
kind = entry.get("kind")
|
|
254
307
|
if kind == "session":
|
|
255
|
-
short_key, order = "
|
|
308
|
+
short_key, order = "session", 0
|
|
256
309
|
elif kind == "weekly_all":
|
|
257
310
|
short_key, order = "7d", 1
|
|
258
311
|
elif kind == "weekly_scoped":
|
|
@@ -511,18 +564,19 @@ def cmd_statusline():
|
|
|
511
564
|
usage = _get_cached_usage()
|
|
512
565
|
|
|
513
566
|
plan = usage.get("plan", "?")
|
|
514
|
-
five_h = usage.get("5h", {})
|
|
515
|
-
|
|
516
567
|
parts = [f"{D}{pwd}{RST}", f"[{C}{model}{RST}]"]
|
|
517
568
|
|
|
518
569
|
# Auto-include every quota bucket present (a newly added one just appears).
|
|
570
|
+
session_bucket = {}
|
|
519
571
|
for key, bucket in _quota_buckets(usage):
|
|
520
572
|
abbrev = _bucket_display(key)[1]
|
|
521
573
|
parts.append(f"{abbrev}:{color_pct(int(bucket.get('pct', 0)))}")
|
|
574
|
+
if key in {"session", "5h"}:
|
|
575
|
+
session_bucket = bucket
|
|
522
576
|
|
|
523
577
|
parts.append(f"| {cost_fmt} | {D}{plan}{RST}")
|
|
524
578
|
|
|
525
|
-
reset = fmt_reset(
|
|
579
|
+
reset = fmt_reset(session_bucket.get("resets_at"))
|
|
526
580
|
if reset:
|
|
527
581
|
parts.append(f"| {D}reset:{reset}{RST}")
|
|
528
582
|
|
|
@@ -546,7 +600,7 @@ def cmd_install():
|
|
|
546
600
|
}
|
|
547
601
|
|
|
548
602
|
3. The statusline reads ~/.claude/usage-limits.json (written by the daemon)
|
|
549
|
-
and shows:
|
|
603
|
+
and shows: session, weekly all-models, and weekly scoped limits.
|
|
550
604
|
""")
|
|
551
605
|
|
|
552
606
|
|
|
@@ -107,6 +107,7 @@ class FetchUsageTests(unittest.TestCase):
|
|
|
107
107
|
self.assertEqual(oauth["subscriptionType"], "max")
|
|
108
108
|
self.assertEqual(on_disk["otherTopLevel"], "keep-me")
|
|
109
109
|
self.assertEqual(self.credfile.stat().st_mode & 0o777, 0o600)
|
|
110
|
+
self.assertEqual(list(self.credfile.parent.glob("..credentials.json.*")), [])
|
|
110
111
|
|
|
111
112
|
def test_rejected_token_retries_once_after_refresh(self):
|
|
112
113
|
self._write_creds(int(time.time() * 1000) + 3_600_000)
|
|
@@ -139,6 +140,52 @@ class FetchUsageTests(unittest.TestCase):
|
|
|
139
140
|
with self.assertRaises(urllib.error.HTTPError):
|
|
140
141
|
ccusage.fetch_usage()
|
|
141
142
|
|
|
143
|
+
def test_rejected_token_reloads_concurrently_updated_credentials(self):
|
|
144
|
+
self._write_creds(int(time.time() * 1000) + 3_600_000)
|
|
145
|
+
replacement = _creds(int(time.time() * 1000) + 3_600_000)
|
|
146
|
+
replacement["claudeAiOauth"]["accessToken"] = "replacement-token"
|
|
147
|
+
replacement["claudeAiOauth"]["refreshToken"] = "replacement-refresh"
|
|
148
|
+
calls = []
|
|
149
|
+
|
|
150
|
+
def fake_urlopen(req, timeout=None):
|
|
151
|
+
calls.append(req.full_url)
|
|
152
|
+
if len(calls) == 1:
|
|
153
|
+
self.credfile.write_text(json.dumps(replacement))
|
|
154
|
+
raise urllib.error.HTTPError(
|
|
155
|
+
req.full_url, 401, "Unauthorized", {}, io.BytesIO(b"")
|
|
156
|
+
)
|
|
157
|
+
self.assertEqual(
|
|
158
|
+
req.headers["Authorization"], "Bearer replacement-token"
|
|
159
|
+
)
|
|
160
|
+
return _json_response({"ok": True})
|
|
161
|
+
|
|
162
|
+
with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
|
|
163
|
+
self.assertEqual(ccusage.fetch_usage(), {"ok": True})
|
|
164
|
+
|
|
165
|
+
self.assertEqual(calls, [USAGE_URL, USAGE_URL])
|
|
166
|
+
|
|
167
|
+
def test_refresh_does_not_overwrite_concurrently_rotated_credentials(self):
|
|
168
|
+
self._write_creds(0)
|
|
169
|
+
original = json.loads(self.credfile.read_text())
|
|
170
|
+
replacement = _creds(int(time.time() * 1000) + 3_600_000)
|
|
171
|
+
replacement["claudeAiOauth"]["accessToken"] = "replacement-token"
|
|
172
|
+
replacement["claudeAiOauth"]["refreshToken"] = "replacement-refresh"
|
|
173
|
+
|
|
174
|
+
class RotatingResponse(_FakeResponse):
|
|
175
|
+
def read(inner_self, *args):
|
|
176
|
+
self.credfile.write_text(json.dumps(replacement))
|
|
177
|
+
return super().read(*args)
|
|
178
|
+
|
|
179
|
+
with mock.patch.object(
|
|
180
|
+
ccusage.urllib.request,
|
|
181
|
+
"urlopen",
|
|
182
|
+
return_value=RotatingResponse(json.dumps(REFRESH_RESULT).encode()),
|
|
183
|
+
):
|
|
184
|
+
result = ccusage.refresh_credentials(original)
|
|
185
|
+
|
|
186
|
+
self.assertEqual(result, replacement)
|
|
187
|
+
self.assertEqual(json.loads(self.credfile.read_text()), replacement)
|
|
188
|
+
|
|
142
189
|
def test_expired_token_without_refresh_token_raises(self):
|
|
143
190
|
creds = _creds(0)
|
|
144
191
|
del creds["claudeAiOauth"]["refreshToken"]
|
|
@@ -177,14 +224,39 @@ class BuildUsageJsonTests(unittest.TestCase):
|
|
|
177
224
|
}
|
|
178
225
|
result = ccusage.build_usage_json(api_data, "max_20x")
|
|
179
226
|
self.assertEqual(result["plan"], "max_20x")
|
|
180
|
-
self.assertEqual(result["
|
|
227
|
+
self.assertEqual(result["session"], {"pct": 3, "resets_at": "2026-07-04T05:10:00+00:00"})
|
|
228
|
+
self.assertNotIn("5h", result)
|
|
181
229
|
self.assertEqual(result["7d"], {"pct": 33, "resets_at": "2026-07-07T13:00:00+00:00"})
|
|
182
230
|
self.assertEqual(result["7d_fable"], {"pct": 58, "resets_at": "2026-07-07T13:00:00+00:00"})
|
|
183
231
|
self.assertEqual(result["extra_usage"], {"is_enabled": True, "monthly_limit": 100000})
|
|
184
232
|
self.assertEqual(ccusage._bucket_display("7d_fable"), ("Week (Fable)", "fab"))
|
|
185
233
|
# Buckets appear in a stable, sensible order.
|
|
186
234
|
keys = [k for k, _ in ccusage._quota_buckets(result)]
|
|
187
|
-
self.assertEqual(keys, ["
|
|
235
|
+
self.assertEqual(keys, ["session", "7d", "7d_fable"])
|
|
236
|
+
|
|
237
|
+
def test_statusline_uses_semantic_session_bucket_for_reset(self):
|
|
238
|
+
usage = {
|
|
239
|
+
"plan": "max_20x",
|
|
240
|
+
"session": {
|
|
241
|
+
"pct": 3,
|
|
242
|
+
"resets_at": "2099-01-01T00:00:00+00:00",
|
|
243
|
+
},
|
|
244
|
+
"7d": {"pct": 13, "resets_at": None},
|
|
245
|
+
}
|
|
246
|
+
status_input = {
|
|
247
|
+
"model": {"display_name": "Test"},
|
|
248
|
+
"workspace": {"current_dir": "/code/test"},
|
|
249
|
+
}
|
|
250
|
+
with (
|
|
251
|
+
mock.patch.object(ccusage, "_get_cached_usage", return_value=usage),
|
|
252
|
+
mock.patch("sys.stdin", io.StringIO(json.dumps(status_input))),
|
|
253
|
+
mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
|
|
254
|
+
):
|
|
255
|
+
ccusage.cmd_statusline()
|
|
256
|
+
|
|
257
|
+
output = stdout.getvalue()
|
|
258
|
+
self.assertIn("sess:3%", output)
|
|
259
|
+
self.assertIn("reset:", output)
|
|
188
260
|
|
|
189
261
|
def test_scoped_limit_without_percent_is_skipped(self):
|
|
190
262
|
api_data = {"limits": [
|
|
File without changes
|
|
File without changes
|
|
File without changes
|