ccusage 0.1.6__tar.gz → 0.1.7__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.6 → ccusage-0.1.7}/PKG-INFO +2 -2
- {ccusage-0.1.6 → ccusage-0.1.7}/README.md +1 -1
- {ccusage-0.1.6 → ccusage-0.1.7}/pyproject.toml +1 -1
- {ccusage-0.1.6 → ccusage-0.1.7}/src/ccusage/__init__.py +80 -15
- ccusage-0.1.7/tests/test_ccusage.py +179 -0
- {ccusage-0.1.6 → ccusage-0.1.7}/.github/workflows/publish.yml +0 -0
- {ccusage-0.1.6 → ccusage-0.1.7}/.gitignore +0 -0
- {ccusage-0.1.6 → ccusage-0.1.7}/.python-version +0 -0
- {ccusage-0.1.6 → ccusage-0.1.7}/src/ccusage/__main__.py +0 -0
- {ccusage-0.1.6 → ccusage-0.1.7}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ccusage
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
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
|
|
@@ -145,7 +145,7 @@ The OAuth token lives at `~/.claude/.credentials.json`:
|
|
|
145
145
|
}
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
The access token expires roughly hourly.
|
|
148
|
+
The access token expires roughly hourly. When it's expired (or rejected with a 401/403), ccusage refreshes it itself using the `refreshToken` and writes the rotated credentials back to `.credentials.json` — the same flow Claude Code uses, so the two stay in sync and no active Claude Code session is needed.
|
|
149
149
|
|
|
150
150
|
### Warning thresholds (from cli.js)
|
|
151
151
|
|
|
@@ -136,7 +136,7 @@ The OAuth token lives at `~/.claude/.credentials.json`:
|
|
|
136
136
|
}
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
-
The access token expires roughly hourly.
|
|
139
|
+
The access token expires roughly hourly. When it's expired (or rejected with a 401/403), ccusage refreshes it itself using the `refreshToken` and writes the rotated credentials back to `.credentials.json` — the same flow Claude Code uses, so the two stay in sync and no active Claude Code session is needed.
|
|
140
140
|
|
|
141
141
|
### Warning thresholds (from cli.js)
|
|
142
142
|
|
|
@@ -15,10 +15,12 @@ Usage:
|
|
|
15
15
|
|
|
16
16
|
import argparse
|
|
17
17
|
import json
|
|
18
|
+
import os
|
|
18
19
|
import signal
|
|
19
20
|
import subprocess
|
|
20
21
|
import sys
|
|
21
22
|
import time
|
|
23
|
+
import urllib.error
|
|
22
24
|
import urllib.request
|
|
23
25
|
from datetime import datetime, timezone
|
|
24
26
|
from pathlib import Path
|
|
@@ -69,6 +71,8 @@ CLAUDE_DIR = Path.home() / ".claude"
|
|
|
69
71
|
CREDENTIALS_FILE = _resolve_claude_path(".credentials.json")
|
|
70
72
|
USAGE_FILE = _resolve_claude_path("usage-limits.json")
|
|
71
73
|
DAEMON_INTERVAL = 300 # 5 minutes
|
|
74
|
+
TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
|
75
|
+
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Claude Code's public OAuth client
|
|
72
76
|
|
|
73
77
|
|
|
74
78
|
def get_credentials() -> dict | None:
|
|
@@ -90,10 +94,59 @@ def get_plan(creds: dict | None = None) -> str:
|
|
|
90
94
|
return tier.removeprefix("default_claude_")
|
|
91
95
|
|
|
92
96
|
|
|
97
|
+
def refresh_credentials(creds: dict) -> dict:
|
|
98
|
+
"""Refresh the OAuth access token and persist updated credentials.
|
|
99
|
+
|
|
100
|
+
Anthropic rotates refresh tokens, so the new refreshToken MUST be written
|
|
101
|
+
back to .credentials.json or Claude Code's stored one goes stale and the
|
|
102
|
+
user gets logged out.
|
|
103
|
+
"""
|
|
104
|
+
oauth = creds.get("claudeAiOauth", {})
|
|
105
|
+
refresh_token = oauth.get("refreshToken")
|
|
106
|
+
if not refresh_token:
|
|
107
|
+
raise RuntimeError("OAuth token expired and no refresh token — open Claude Code to log in")
|
|
108
|
+
|
|
109
|
+
payload = json.dumps({
|
|
110
|
+
"grant_type": "refresh_token",
|
|
111
|
+
"refresh_token": refresh_token,
|
|
112
|
+
"client_id": CLIENT_ID,
|
|
113
|
+
}).encode()
|
|
114
|
+
req = urllib.request.Request(
|
|
115
|
+
TOKEN_URL,
|
|
116
|
+
data=payload,
|
|
117
|
+
headers={"Content-Type": "application/json"},
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
121
|
+
result = json.loads(resp.read())
|
|
122
|
+
except urllib.error.HTTPError as e:
|
|
123
|
+
raise RuntimeError(f"Token refresh failed ({e.code}) — open Claude Code to refresh it") from e
|
|
124
|
+
|
|
125
|
+
oauth = dict(oauth)
|
|
126
|
+
oauth["accessToken"] = result["access_token"]
|
|
127
|
+
if result.get("refresh_token"):
|
|
128
|
+
oauth["refreshToken"] = result["refresh_token"]
|
|
129
|
+
oauth["expiresAt"] = int(time.time() * 1000 + int(result.get("expires_in", 3600)) * 1000)
|
|
130
|
+
updated = dict(creds)
|
|
131
|
+
updated["claudeAiOauth"] = oauth
|
|
132
|
+
|
|
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
|
|
143
|
+
|
|
144
|
+
|
|
93
145
|
def fetch_usage() -> dict:
|
|
94
146
|
"""Fetch usage from Anthropic's /api/oauth/usage endpoint.
|
|
95
147
|
|
|
96
|
-
|
|
148
|
+
Reads the OAuth token from ~/.claude/.credentials.json, auto-refreshing it
|
|
149
|
+
(and persisting the rotated credentials) when expired or rejected.
|
|
97
150
|
The key header is `anthropic-beta: oauth-2025-04-20` — without it, the
|
|
98
151
|
endpoint returns an auth error.
|
|
99
152
|
|
|
@@ -115,21 +168,33 @@ def fetch_usage() -> dict:
|
|
|
115
168
|
if not token:
|
|
116
169
|
raise RuntimeError("No OAuth access token in credentials")
|
|
117
170
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
171
|
+
# Refresh proactively if expired (or about to), then retry once on 401/403
|
|
172
|
+
# in case the token was revoked early.
|
|
173
|
+
if time.time() * 1000 > oauth.get("expiresAt", 0) - 60_000:
|
|
174
|
+
creds = refresh_credentials(creds)
|
|
175
|
+
token = creds["claudeAiOauth"]["accessToken"]
|
|
176
|
+
|
|
177
|
+
for attempt in range(2):
|
|
178
|
+
req = urllib.request.Request(
|
|
179
|
+
"https://api.anthropic.com/api/oauth/usage",
|
|
180
|
+
headers={
|
|
181
|
+
"Authorization": f"Bearer {token}",
|
|
182
|
+
"Content-Type": "application/json",
|
|
183
|
+
"User-Agent": "claude-code/2.1.71",
|
|
184
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
189
|
+
return json.loads(resp.read())
|
|
190
|
+
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:
|
|
195
|
+
raise
|
|
121
196
|
|
|
122
|
-
|
|
123
|
-
"https://api.anthropic.com/api/oauth/usage",
|
|
124
|
-
headers={
|
|
125
|
-
"Authorization": f"Bearer {token}",
|
|
126
|
-
"Content-Type": "application/json",
|
|
127
|
-
"User-Agent": "claude-code/2.1.71",
|
|
128
|
-
"anthropic-beta": "oauth-2025-04-20",
|
|
129
|
-
},
|
|
130
|
-
)
|
|
131
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
132
|
-
return json.loads(resp.read())
|
|
197
|
+
raise RuntimeError("Failed to fetch usage after token refresh")
|
|
133
198
|
|
|
134
199
|
|
|
135
200
|
def build_usage_json(api_data: dict, plan: str) -> dict:
|
|
@@ -0,0 +1,179 @@
|
|
|
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_maps_buckets_and_extra_usage(self):
|
|
163
|
+
api_data = {
|
|
164
|
+
"five_hour": {"utilization": 35.0, "resets_at": "2026-06-12T00:00:00+00:00"},
|
|
165
|
+
"seven_day": {"utilization": 14.0, "resets_at": None},
|
|
166
|
+
"seven_day_sonnet": None,
|
|
167
|
+
"seven_day_opus": None,
|
|
168
|
+
"extra_usage": {"is_enabled": True, "monthly_limit": 100000},
|
|
169
|
+
}
|
|
170
|
+
result = ccusage.build_usage_json(api_data, "max_20x")
|
|
171
|
+
self.assertEqual(result["plan"], "max_20x")
|
|
172
|
+
self.assertEqual(result["5h"], {"pct": 35.0, "resets_at": "2026-06-12T00:00:00+00:00"})
|
|
173
|
+
self.assertEqual(result["7d"], {"pct": 14.0, "resets_at": None})
|
|
174
|
+
self.assertNotIn("7d_sonnet", result)
|
|
175
|
+
self.assertEqual(result["extra_usage"], {"is_enabled": True, "monthly_limit": 100000})
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|