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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccusage
3
- Version: 0.1.6
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. Claude Code refreshes it automatically as long as you have an active Claude Code session, the token stays valid for ccusage to read.
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. Claude Code refreshes it automatically as long as you have an active Claude Code session, the token stays valid for ccusage to read.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.1.6"
3
+ version = "0.1.7"
4
4
  description = "Claude Code usage monitor — fetches rate limits from Anthropic's API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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
- Requires a valid (non-expired) OAuth token from ~/.claude/.credentials.json.
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
- expires_at = oauth.get("expiresAt", 0)
119
- if time.time() * 1000 > expires_at:
120
- raise RuntimeError("OAuth token expired open Claude Code to refresh it")
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
- req = urllib.request.Request(
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