clanker-usage 0.4.0__py3-none-any.whl
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.
- clanker_usage/__init__.py +5 -0
- clanker_usage/__main__.py +3 -0
- clanker_usage/cache.py +58 -0
- clanker_usage/cli.py +400 -0
- clanker_usage/errors.py +10 -0
- clanker_usage/http.py +68 -0
- clanker_usage/oauth.py +29 -0
- clanker_usage/paths.py +38 -0
- clanker_usage/providers/__init__.py +13 -0
- clanker_usage/providers/agy.py +189 -0
- clanker_usage/providers/claude.py +236 -0
- clanker_usage/providers/codex.py +243 -0
- clanker_usage/providers/cursor.py +122 -0
- clanker_usage/providers/deepseek.py +409 -0
- clanker_usage/providers/devin.py +119 -0
- clanker_usage/providers/gemini.py +192 -0
- clanker_usage/providers/google.py +63 -0
- clanker_usage/providers/opencode.py +96 -0
- clanker_usage/providers/zcode.py +117 -0
- clanker_usage/shapes/agy/agy-2026-10-19.json +59 -0
- clanker_usage/shapes/claude/claude-2026-10-19.json +54 -0
- clanker_usage/shapes/codex/api-2026-10-19.json +37 -0
- clanker_usage/shapes/codex/app-server-2026-10-19.json +56 -0
- clanker_usage/shapes/cursor/cursor-2026-10-19.json +35 -0
- clanker_usage/shapes/deepseek/deepseek-2026-10-19.json +11 -0
- clanker_usage/shapes/devin/devin-2026-10-19.json +17 -0
- clanker_usage/shapes/gemini/from-gemini-cli-0.56-source.json +12 -0
- clanker_usage/shapes/opencode/opencode-2026-10-19.json +30 -0
- clanker_usage/shapes/zcode/no-plan-2026-10-18.json +5 -0
- clanker_usage/shapes/zcode/quota-2026-09-05.json +29 -0
- clanker_usage/shapes.py +88 -0
- clanker_usage-0.4.0.dist-info/METADATA +228 -0
- clanker_usage-0.4.0.dist-info/RECORD +36 -0
- clanker_usage-0.4.0.dist-info/WHEEL +4 -0
- clanker_usage-0.4.0.dist-info/entry_points.txt +2 -0
- clanker_usage-0.4.0.dist-info/licenses/LICENSE +21 -0
clanker_usage/cache.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Private, atomically written JSON files for caches and check state."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
LIMIT = 1 << 20
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_json(path):
|
|
13
|
+
"""The object stored at path, or None when it is missing, oversized or malformed."""
|
|
14
|
+
try:
|
|
15
|
+
with open(path, "rb") as stream:
|
|
16
|
+
raw = stream.read(LIMIT + 1)
|
|
17
|
+
data = json.loads(raw) if len(raw) <= LIMIT else None
|
|
18
|
+
except (OSError, ValueError):
|
|
19
|
+
return None
|
|
20
|
+
return data if isinstance(data, dict) else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_json(path, data):
|
|
24
|
+
"""Replace path with data through a private temporary file in the same directory."""
|
|
25
|
+
path = Path(path)
|
|
26
|
+
# mkdir(parents=True) gives only the last directory the mode, so create each missing one privately.
|
|
27
|
+
for folder in reversed([path.parent, *path.parent.parents]):
|
|
28
|
+
if not folder.exists():
|
|
29
|
+
# Another writer, such as a parallel provider in the all-accounts view, may create it first.
|
|
30
|
+
folder.mkdir(mode=0o700, exist_ok=True)
|
|
31
|
+
fd, name = tempfile.mkstemp(prefix=".usage-", dir=path.parent)
|
|
32
|
+
try:
|
|
33
|
+
with os.fdopen(fd, "w") as stream:
|
|
34
|
+
json.dump(data, stream, indent=2)
|
|
35
|
+
stream.write("\n")
|
|
36
|
+
stream.flush()
|
|
37
|
+
os.fsync(stream.fileno())
|
|
38
|
+
os.replace(name, path)
|
|
39
|
+
finally:
|
|
40
|
+
Path(name).unlink(missing_ok=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def age(record, key="retrieved_at"):
|
|
44
|
+
"""Seconds since the record's time under key, or None when it is missing or malformed."""
|
|
45
|
+
try:
|
|
46
|
+
updated = datetime.fromisoformat(record[key])
|
|
47
|
+
return (datetime.now(timezone.utc) - updated).total_seconds()
|
|
48
|
+
except (KeyError, TypeError, ValueError):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_fresh(record, max_age):
|
|
53
|
+
return is_fresh_at(record, "retrieved_at", max_age)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_fresh_at(record, key, max_age):
|
|
57
|
+
seconds = age(record, key)
|
|
58
|
+
return seconds is not None and 0 <= seconds < max_age
|
clanker_usage/cli.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Show the plan and quota of coding-agent accounts, read from each provider's own API."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import select
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import cache, paths, shapes
|
|
17
|
+
from .errors import ExpectedUnavailable
|
|
18
|
+
from .providers import PROVIDERS
|
|
19
|
+
|
|
20
|
+
CORE = "clanker-usage"
|
|
21
|
+
SCHEMA = "usage-v1"
|
|
22
|
+
ERRORS = (RuntimeError, ValueError, OSError, OverflowError)
|
|
23
|
+
FETCH_ERRORS = (RuntimeError, ValueError, OverflowError)
|
|
24
|
+
STATUSLINE_MAX_AGE = 300
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def package_version(package):
|
|
28
|
+
try:
|
|
29
|
+
return version(package)
|
|
30
|
+
except PackageNotFoundError:
|
|
31
|
+
return version(CORE)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def now():
|
|
35
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def document(provider, account, status, body=None, error=None, retrieved_at=None):
|
|
39
|
+
"""A usage-v1 document: the same fields for every provider, with provider-only data under details."""
|
|
40
|
+
body = body or {"plan": None, "windows": [], "details": {}}
|
|
41
|
+
return {
|
|
42
|
+
"schema": SCHEMA, "provider": provider.NAME, "account": account, "plan": body["plan"],
|
|
43
|
+
"status": status, "error": error, "retrieved_at": retrieved_at, "windows": body["windows"],
|
|
44
|
+
"balance": body.get("balance"), "details": body["details"],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cached(provider, account):
|
|
49
|
+
record = cache.read_json(paths.cache_file(provider.NAME, account))
|
|
50
|
+
if record and record.get("schema") == SCHEMA and record.get("account") == account:
|
|
51
|
+
return record
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fetch(provider, account):
|
|
56
|
+
"""Fetch live, save the document in the account's cache, and return it."""
|
|
57
|
+
record = document(provider, account, "live", provider.normalize(provider.fetch()), retrieved_at=now())
|
|
58
|
+
cache.write_json(paths.cache_file(provider.NAME, account), record)
|
|
59
|
+
return record
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def unavailable(provider, account, error):
|
|
63
|
+
"""Save that the account has no quota to read, replacing its old windows, so they are not shown as current."""
|
|
64
|
+
record = document(provider, account, "unavailable", error=error, retrieved_at=now())
|
|
65
|
+
cache.write_json(paths.cache_file(provider.NAME, account), record)
|
|
66
|
+
paths.backoff_file(provider.NAME, account).unlink(missing_ok=True)
|
|
67
|
+
return record
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def backoff_seconds(failures):
|
|
71
|
+
"""How long repeated failures pause fetching: one minute, doubling up to 30 minutes."""
|
|
72
|
+
return min(60 * 2 ** (failures - 1), 1800)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load(provider, max_age=0):
|
|
76
|
+
"""The provider's document: cached when younger than max_age, else live, else stale or unavailable.
|
|
77
|
+
|
|
78
|
+
With max_age above 0, as for a statusline, a recent failure pauses fetching, so repeated
|
|
79
|
+
prompts during an outage do not each retry. max_age 0 always fetches.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
account = provider.account()
|
|
83
|
+
except FETCH_ERRORS as exc:
|
|
84
|
+
return document(provider, None, "unavailable", error=str(exc))
|
|
85
|
+
previous = cached(provider, account)
|
|
86
|
+
if previous and max_age > 0 and cache.is_fresh(previous, max_age):
|
|
87
|
+
return previous if previous["status"] == "unavailable" else {**previous, "status": "cached"}
|
|
88
|
+
backoff = paths.backoff_file(provider.NAME, account)
|
|
89
|
+
failure = cache.read_json(backoff) or {}
|
|
90
|
+
failures = failure.get("failures") if isinstance(failure.get("failures"), int) else 0
|
|
91
|
+
if max_age > 0 and failures and cache.is_fresh_at(failure, "failed_at", backoff_seconds(failures)):
|
|
92
|
+
error = str(failure.get("error"))
|
|
93
|
+
else:
|
|
94
|
+
try:
|
|
95
|
+
record = fetch(provider, account)
|
|
96
|
+
except ExpectedUnavailable as exc:
|
|
97
|
+
return unavailable(provider, account, str(exc))
|
|
98
|
+
except FETCH_ERRORS as exc:
|
|
99
|
+
error = str(exc)
|
|
100
|
+
cache.write_json(backoff, {"failed_at": now(), "failures": failures + 1, "error": error})
|
|
101
|
+
else:
|
|
102
|
+
backoff.unlink(missing_ok=True)
|
|
103
|
+
return record
|
|
104
|
+
if previous and previous["status"] != "unavailable":
|
|
105
|
+
return {**previous, "status": "stale", "error": error}
|
|
106
|
+
return document(provider, account, "unavailable", error=error)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def short_duration(iso):
|
|
110
|
+
"""Time until iso, such as 2.9h or 4.7d; empty once it has passed."""
|
|
111
|
+
hours = (datetime.fromisoformat(iso) - datetime.now(timezone.utc)).total_seconds() / 3600
|
|
112
|
+
if hours <= 0:
|
|
113
|
+
return ""
|
|
114
|
+
return f"{hours / 24:.1f}d" if hours >= 48 else f"{hours:.1f}h"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pct(value):
|
|
118
|
+
"""A share for display, to one decimal place at most; JSON keeps the exact value."""
|
|
119
|
+
return f"{round(value, 1):g}%"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def label(window):
|
|
123
|
+
"""The window's name, after its scope when the scope adds something, such as "Gemini weekly"."""
|
|
124
|
+
scope = window.get("scope")
|
|
125
|
+
return f"{scope} {window['name']}" if scope and scope != window["name"] else window["name"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def money(item):
|
|
129
|
+
return f"{item['amount']:.2f} {item['currency']}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def status_note(record):
|
|
133
|
+
if record["status"] == "unavailable":
|
|
134
|
+
return f"unavailable: {record['error']}"
|
|
135
|
+
if record["status"] == "stale":
|
|
136
|
+
return f"stale, from {record['retrieved_at']}; live fetch failed: {record['error']}"
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render(provider, record):
|
|
141
|
+
lines = [f"{provider.LABEL}: {record['plan'] or 'no plan'}"]
|
|
142
|
+
lines += [f" balance: {money(item)}" for item in record.get("balance") or []]
|
|
143
|
+
for window in record["windows"]:
|
|
144
|
+
line = f" {label(window)}: {pct(window['used_pct'])} used"
|
|
145
|
+
if window["resets_at"]:
|
|
146
|
+
reset = datetime.fromisoformat(window["resets_at"]).astimezone()
|
|
147
|
+
line += f", resets {reset:%Y-%m-%d %H:%M %Z}"
|
|
148
|
+
lines.append(line)
|
|
149
|
+
note = status_note(record)
|
|
150
|
+
return "\n".join(lines + ([f" ({note})"] if note else []))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def harness_prefix(status):
|
|
154
|
+
"""The directory, model and cost from the status JSON a harness pipes to its statusline, as Claude Code does."""
|
|
155
|
+
if not isinstance(status, dict):
|
|
156
|
+
return ""
|
|
157
|
+
parts = []
|
|
158
|
+
directory = (status.get("workspace") or {}).get("current_dir") if isinstance(status.get("workspace"), dict) else None
|
|
159
|
+
if isinstance(directory, str) and directory:
|
|
160
|
+
home = str(Path.home())
|
|
161
|
+
parts.append("~" + directory[len(home):] if directory == home or directory.startswith(home + "/") else directory)
|
|
162
|
+
model = (status.get("model") or {}).get("display_name") if isinstance(status.get("model"), dict) else None
|
|
163
|
+
if isinstance(model, str) and model:
|
|
164
|
+
parts.append(f"[{model}]")
|
|
165
|
+
cost = (status.get("cost") or {}).get("total_cost_usd") if isinstance(status.get("cost"), dict) else None
|
|
166
|
+
if isinstance(cost, int | float) and not isinstance(cost, bool):
|
|
167
|
+
parts.append(f"${cost:.2f}")
|
|
168
|
+
return " ".join(parts) + " | " if parts else ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def read_harness_status():
|
|
172
|
+
"""The JSON a harness writes to the statusline's stdin, or None when stdin is a terminal or holds none."""
|
|
173
|
+
if sys.stdin is None or sys.stdin.isatty():
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
if os.name == "posix" and not select.select([sys.stdin], [], [], 0.5)[0]:
|
|
177
|
+
return None
|
|
178
|
+
return json.loads(sys.stdin.read() or "null")
|
|
179
|
+
except (OSError, ValueError):
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def statusline(provider, record):
|
|
184
|
+
if record["status"] == "unavailable":
|
|
185
|
+
return f"{provider.LABEL} unavailable"
|
|
186
|
+
parts = [f"{provider.LABEL} {record['plan'] or 'no plan'}"]
|
|
187
|
+
parts += [money(item) for item in record.get("balance") or []]
|
|
188
|
+
for window in record["windows"]:
|
|
189
|
+
part = f"{label(window)} {pct(window['used_pct'])}"
|
|
190
|
+
until = short_duration(window["resets_at"]) if window["resets_at"] else ""
|
|
191
|
+
if until:
|
|
192
|
+
part += f" ({until})"
|
|
193
|
+
parts.append(part)
|
|
194
|
+
return " · ".join(parts) + (" (stale)" if record["status"] == "stale" else "")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def table(records):
|
|
198
|
+
"""One row per window of every provider's document."""
|
|
199
|
+
rows = [("account", "plan", "window", "used", "resets in", "note")]
|
|
200
|
+
for provider, record in records:
|
|
201
|
+
name = provider.LABEL + (f" {record['account'][:8]}" if record["account"] else "")
|
|
202
|
+
note = status_note(record) or ""
|
|
203
|
+
for item in record.get("balance") or []:
|
|
204
|
+
rows.append((name, record["plan"] or "", "balance", money(item), "", note))
|
|
205
|
+
if not record["windows"] and not record.get("balance"):
|
|
206
|
+
rows.append((name, record["plan"] or "", "-", "", "", note or ("no plan" if not record["plan"] else "")))
|
|
207
|
+
for window in record["windows"]:
|
|
208
|
+
rows.append((name, record["plan"] or "", label(window), pct(window["used_pct"]),
|
|
209
|
+
short_duration(window["resets_at"]) if window["resets_at"] else "", note))
|
|
210
|
+
widths = [max(len(row[i]) for row in rows) for i in range(5)]
|
|
211
|
+
return "\n".join((" ".join(cell.ljust(width) for cell, width in zip(row[:5], widths)) + " " + row[5]).rstrip()
|
|
212
|
+
for row in rows)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def check_one(provider, record_dir=None):
|
|
216
|
+
"""Fetch live, compare the response's structure with the recorded shapes, and save the outcome.
|
|
217
|
+
|
|
218
|
+
The result is error, new-shape, missing (a window the previous check saw is absent), ok, or
|
|
219
|
+
expected-unavailable when the login has no quota to read, such as Gemini CLI on an API key.
|
|
220
|
+
The saved state holds paths and window names, never values.
|
|
221
|
+
"""
|
|
222
|
+
state_path = paths.state_file(provider.NAME)
|
|
223
|
+
previous = cache.read_json(state_path) or {}
|
|
224
|
+
state = {"checked_at": now(), "result": "ok", "details": [], "windows": previous.get("windows", [])}
|
|
225
|
+
try:
|
|
226
|
+
payload = provider.fetch()
|
|
227
|
+
if record_dir:
|
|
228
|
+
path = Path(record_dir) / f"{provider.NAME}-{datetime.now(timezone.utc):%Y-%m-%d}.json"
|
|
229
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
path.write_text(json.dumps(shapes.synthetic(payload, provider.KEEP), indent=2, ensure_ascii=False) + "\n")
|
|
231
|
+
state["details"].append(f"recorded {path}")
|
|
232
|
+
new = sorted(shapes.paths(payload) - shapes.known_paths(provider.NAME))
|
|
233
|
+
if new:
|
|
234
|
+
state.update(result="new-shape")
|
|
235
|
+
state["details"] += new
|
|
236
|
+
names = [window["name"] for window in provider.normalize(payload)["windows"]]
|
|
237
|
+
except ExpectedUnavailable as exc:
|
|
238
|
+
state["result"] = "expected-unavailable"
|
|
239
|
+
state["details"].append(str(exc))
|
|
240
|
+
except ERRORS as exc:
|
|
241
|
+
state["result"] = "error"
|
|
242
|
+
state["details"].append(str(exc))
|
|
243
|
+
else:
|
|
244
|
+
missing = [name for name in state["windows"] if name not in names]
|
|
245
|
+
if missing and state["result"] == "ok":
|
|
246
|
+
state["result"] = "missing"
|
|
247
|
+
state["details"] += [f"missing window: {name}" for name in missing]
|
|
248
|
+
state["windows"] = names
|
|
249
|
+
cache.write_json(state_path, state)
|
|
250
|
+
return state
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def check(names, record_dir=None):
|
|
254
|
+
failed = False
|
|
255
|
+
for name in names:
|
|
256
|
+
state = check_one(PROVIDERS[name], record_dir)
|
|
257
|
+
print(f"{name}: {state['result']}" + "".join(f"\n {line}" for line in state["details"]))
|
|
258
|
+
failed |= state["result"] not in ("ok", "expected-unavailable")
|
|
259
|
+
return 1 if failed else 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def daemon(provider, interval):
|
|
263
|
+
"""Refresh the account's cache every interval, skipping a refresh another process just made."""
|
|
264
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
265
|
+
backoff = interval
|
|
266
|
+
while True:
|
|
267
|
+
try:
|
|
268
|
+
account = provider.account()
|
|
269
|
+
previous = cached(provider, account)
|
|
270
|
+
if not (previous and cache.is_fresh(previous, interval)):
|
|
271
|
+
print(statusline(provider, fetch(provider, account)), flush=True)
|
|
272
|
+
backoff = interval
|
|
273
|
+
except ExpectedUnavailable as exc:
|
|
274
|
+
# Such as an account without a subscription, which an hour rarely changes.
|
|
275
|
+
print(statusline(provider, unavailable(provider, account, str(exc))), flush=True)
|
|
276
|
+
backoff = max(interval, 3600)
|
|
277
|
+
except ERRORS as exc:
|
|
278
|
+
print(str(exc), file=sys.stderr, flush=True)
|
|
279
|
+
backoff = min(backoff * 2, max(interval, 3600))
|
|
280
|
+
time.sleep(backoff)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def positive_int(value):
|
|
284
|
+
result = int(value)
|
|
285
|
+
if result <= 0:
|
|
286
|
+
raise argparse.ArgumentTypeError("must be positive")
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def nonnegative_int(value):
|
|
291
|
+
result = int(value)
|
|
292
|
+
if result < 0:
|
|
293
|
+
raise argparse.ArgumentTypeError("must not be negative")
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def add_max_age(parser, default):
|
|
298
|
+
parser.add_argument("--max-age", type=nonnegative_int, default=default, metavar="SECONDS",
|
|
299
|
+
help=f"reuse a cache younger than this; 0 always fetches (default: {default})")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def add_provider_commands(parser, provider):
|
|
303
|
+
sub = parser.add_subparsers(dest="command")
|
|
304
|
+
add_max_age(sub.add_parser("status", help="Show current quotas (the default)"), 0)
|
|
305
|
+
add_max_age(sub.add_parser("json", help="Print the usage-v1 document"), 0)
|
|
306
|
+
add_max_age(sub.add_parser("statusline", help="Print one line for a status bar"), STATUSLINE_MAX_AGE)
|
|
307
|
+
sub.add_parser("refresh", help="Fetch and update the cache; fail if unavailable")
|
|
308
|
+
daemon_parser = sub.add_parser("daemon", help=f"Refresh the cache every {provider.INTERVAL} seconds")
|
|
309
|
+
daemon_parser.add_argument("--interval", type=positive_int, default=provider.INTERVAL, metavar="SECONDS")
|
|
310
|
+
checker = sub.add_parser("check", help="Compare a live response with the recorded response shapes")
|
|
311
|
+
checker.add_argument("--record", metavar="DIR", type=Path, help="also save a synthetic copy of the response in DIR")
|
|
312
|
+
if hasattr(provider, "add_commands"):
|
|
313
|
+
provider.add_commands(sub)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def run_provider(provider, args):
|
|
317
|
+
if hasattr(provider, "run_command") and provider.run_command(args):
|
|
318
|
+
return 0
|
|
319
|
+
command = args.command or "status"
|
|
320
|
+
if command == "daemon":
|
|
321
|
+
daemon(provider, args.interval)
|
|
322
|
+
if command == "check":
|
|
323
|
+
return check([provider.NAME], args.record)
|
|
324
|
+
if command == "refresh":
|
|
325
|
+
account = provider.account()
|
|
326
|
+
fetch(provider, account)
|
|
327
|
+
print(f"Updated {paths.cache_file(provider.NAME, account)}")
|
|
328
|
+
return 0
|
|
329
|
+
# A bare provider command is status with its default; the core's own --max-age is for the all-accounts view.
|
|
330
|
+
record = load(provider, args.max_age if args.command else 0)
|
|
331
|
+
if command == "json":
|
|
332
|
+
print(json.dumps(record, indent=2, ensure_ascii=False))
|
|
333
|
+
elif command == "statusline":
|
|
334
|
+
print(harness_prefix(read_harness_status()) + statusline(provider, record))
|
|
335
|
+
else:
|
|
336
|
+
print(render(provider, record))
|
|
337
|
+
return 1 if record["status"] == "unavailable" else 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def show_all(max_age, as_json):
|
|
341
|
+
"""Every account of every provider, including each config directory a provider was configured with."""
|
|
342
|
+
tasks = [(provider, selector) for provider in PROVIDERS.values()
|
|
343
|
+
for selector in (provider.selectors() if hasattr(provider, "selectors") else [None])]
|
|
344
|
+
|
|
345
|
+
def run(task):
|
|
346
|
+
provider, selector = task
|
|
347
|
+
with provider.using(selector) if selector is not None else contextlib.nullcontext():
|
|
348
|
+
return provider, load(provider, max_age)
|
|
349
|
+
|
|
350
|
+
with ThreadPoolExecutor(len(tasks)) as pool:
|
|
351
|
+
results = list(pool.map(run, tasks))
|
|
352
|
+
seen, rows = set(), []
|
|
353
|
+
for provider, record in results:
|
|
354
|
+
# Two config directories signed into one account show it once.
|
|
355
|
+
key = (provider.NAME, record["account"] or id(record))
|
|
356
|
+
if key not in seen:
|
|
357
|
+
seen.add(key)
|
|
358
|
+
rows.append((provider, record))
|
|
359
|
+
if as_json:
|
|
360
|
+
print(json.dumps([record for _, record in rows], indent=2, ensure_ascii=False))
|
|
361
|
+
else:
|
|
362
|
+
print(table(rows))
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def guarded(function, *args):
|
|
367
|
+
try:
|
|
368
|
+
return function(*args)
|
|
369
|
+
except ERRORS as exc:
|
|
370
|
+
print(str(exc), file=sys.stderr)
|
|
371
|
+
return 1
|
|
372
|
+
except KeyboardInterrupt:
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def run(name, argv=None):
|
|
377
|
+
"""The command of one provider's package, such as zcode-cli-usage."""
|
|
378
|
+
provider = PROVIDERS[name]
|
|
379
|
+
parser = argparse.ArgumentParser(prog=provider.PACKAGE, description=provider.DESCRIPTION)
|
|
380
|
+
parser.add_argument("--version", action="version", version=package_version(provider.PACKAGE))
|
|
381
|
+
add_provider_commands(parser, provider)
|
|
382
|
+
return guarded(run_provider, provider, parser.parse_args(argv))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def main(argv=None):
|
|
386
|
+
parser = argparse.ArgumentParser(prog=CORE, description=__doc__)
|
|
387
|
+
parser.add_argument("--version", action="version", version=version(CORE))
|
|
388
|
+
parser.add_argument("--json", action="store_true", help="print every account's usage-v1 document")
|
|
389
|
+
add_max_age(parser, STATUSLINE_MAX_AGE)
|
|
390
|
+
sub = parser.add_subparsers(dest="provider", metavar="{check," + ",".join(PROVIDERS) + "}")
|
|
391
|
+
checker = sub.add_parser("check", help="Check every provider's live response against its recorded shapes")
|
|
392
|
+
checker.add_argument("--record", metavar="DIR", type=Path, help="also save synthetic copies of the responses in DIR")
|
|
393
|
+
for name, provider in PROVIDERS.items():
|
|
394
|
+
add_provider_commands(sub.add_parser(name, help=provider.DESCRIPTION), provider)
|
|
395
|
+
args = parser.parse_args(argv)
|
|
396
|
+
if args.provider is None:
|
|
397
|
+
return guarded(show_all, args.max_age, args.json)
|
|
398
|
+
if args.provider == "check":
|
|
399
|
+
return guarded(check, list(PROVIDERS), args.record)
|
|
400
|
+
return guarded(run_provider, PROVIDERS[args.provider], args)
|
clanker_usage/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Errors a provider raises that the core treats specially."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExpectedUnavailable(RuntimeError):
|
|
5
|
+
"""The account, or the login in use, has no quota to read: Gemini CLI on an API key, or a Claude
|
|
6
|
+
account without a subscription.
|
|
7
|
+
|
|
8
|
+
The document is unavailable and, once the account is known, cached as such, replacing its old
|
|
9
|
+
windows. check reports expected-unavailable instead of an error.
|
|
10
|
+
"""
|
clanker_usage/http.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Authenticated JSON requests that never follow redirects and never echo secrets."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
LIMIT = 1 << 20
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
13
|
+
def redirect_request(self, *args, **kwargs):
|
|
14
|
+
# A redirect surfaces as an HTTPError, so credentials never reach another URL.
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_opener = urllib.request.build_opener(_NoRedirect)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StatusError(RuntimeError):
|
|
22
|
+
"""An HTTP error status, kept as .code so a caller can retry after renewing a token."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message, code):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.code = code
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def user_agent():
|
|
30
|
+
try:
|
|
31
|
+
return f"clanker-usage/{version('clanker-usage')}"
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
return "clanker-usage"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def request_json(url, headers, body=None, form=None, timeout=15):
|
|
37
|
+
"""GET url, or POST body as JSON or form as a URL-encoded form, and return the decoded response.
|
|
38
|
+
|
|
39
|
+
Errors name only the status or failure kind, never a header, body or credential.
|
|
40
|
+
"""
|
|
41
|
+
headers = {"User-Agent": user_agent(), "Accept": "application/json", **headers}
|
|
42
|
+
if not all(value.isprintable() for value in headers.values()):
|
|
43
|
+
raise RuntimeError("Invalid credential: contains control characters")
|
|
44
|
+
data = None
|
|
45
|
+
if body is not None:
|
|
46
|
+
data = json.dumps(body, separators=(",", ":")).encode()
|
|
47
|
+
headers.setdefault("Content-Type", "application/json")
|
|
48
|
+
elif form is not None:
|
|
49
|
+
data = urllib.parse.urlencode(form).encode()
|
|
50
|
+
headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
|
51
|
+
request = urllib.request.Request(url, data=data, headers=headers, method="POST" if data is not None else "GET")
|
|
52
|
+
try:
|
|
53
|
+
with _opener.open(request, timeout=timeout) as response:
|
|
54
|
+
raw = response.read(LIMIT + 1)
|
|
55
|
+
except urllib.error.HTTPError as exc:
|
|
56
|
+
# Never include response bodies or request headers in diagnostics.
|
|
57
|
+
if exc.code in (401, 403):
|
|
58
|
+
raise StatusError(f"Credential rejected (HTTP {exc.code}); sign in again with the provider's own client",
|
|
59
|
+
exc.code) from None
|
|
60
|
+
raise StatusError(f"Usage request failed (HTTP {exc.code})", exc.code) from None
|
|
61
|
+
except (urllib.error.URLError, TimeoutError):
|
|
62
|
+
raise RuntimeError("Could not connect to the usage API") from None
|
|
63
|
+
if len(raw) > LIMIT:
|
|
64
|
+
raise RuntimeError("Usage response exceeds 1 MiB")
|
|
65
|
+
try:
|
|
66
|
+
return json.loads(raw)
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise ValueError("Usage response is not valid JSON") from None
|
clanker_usage/oauth.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Renewing an OAuth access token that the owning client keeps in a file.
|
|
2
|
+
|
|
3
|
+
A provider renews the token only when the owning client left it expired, and replaces the file
|
|
4
|
+
only if nobody changed it meanwhile, so a renewal the owning client completed is never undone.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from . import cache, http
|
|
8
|
+
|
|
9
|
+
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def google_refresh(refresh_token, client_id, client_secret):
|
|
13
|
+
"""A new access token from Google's token endpoint. A rejected refresh raises http.StatusError."""
|
|
14
|
+
return http.request_json(GOOGLE_TOKEN_URL, {}, form={
|
|
15
|
+
"grant_type": "refresh_token", "refresh_token": refresh_token,
|
|
16
|
+
"client_id": client_id, "client_secret": client_secret,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def replace_if_unchanged(path, expected, identity, updated):
|
|
21
|
+
"""Save updated at path unless the file's identity is no longer expected; return what the file now holds.
|
|
22
|
+
|
|
23
|
+
identity maps the file's contents, such as its refresh and access tokens, to something comparable.
|
|
24
|
+
"""
|
|
25
|
+
latest = cache.read_json(path)
|
|
26
|
+
if latest is not None and identity(latest) != expected:
|
|
27
|
+
return latest
|
|
28
|
+
cache.write_json(path, updated)
|
|
29
|
+
return updated
|
clanker_usage/paths.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Where clanker-usage keeps its cache, check state and configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import cache
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _base(variable, default):
|
|
10
|
+
return Path(os.environ.get(variable) or Path.home() / default) / "clanker-usage"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cache_file(provider, account):
|
|
14
|
+
return _base("XDG_CACHE_HOME", ".cache") / provider / f"{account}.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def state_file(provider):
|
|
18
|
+
return _base("XDG_STATE_HOME", ".local/state") / "check" / f"{provider}.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def backoff_file(provider, account):
|
|
22
|
+
return _base("XDG_STATE_HOME", ".local/state") / "backoff" / provider / f"{account}.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config_file():
|
|
26
|
+
return _base("XDG_CONFIG_HOME", ".config") / "config.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def config(provider):
|
|
30
|
+
"""The provider's section of the configuration file, empty when unset."""
|
|
31
|
+
section = (cache.read_json(config_file()) or {}).get(provider)
|
|
32
|
+
return section if isinstance(section, dict) else {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_config(provider, section):
|
|
36
|
+
data = cache.read_json(config_file()) or {}
|
|
37
|
+
data[provider] = section
|
|
38
|
+
cache.write_json(config_file(), data)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""One module per account provider. Each defines the names and functions that cli.py calls:
|
|
2
|
+
|
|
3
|
+
NAME, LABEL, PACKAGE, DESCRIPTION, INTERVAL and KEEP; account(), a stable id or credential
|
|
4
|
+
fingerprint; fetch(), the raw response, reduced to the parts the provider reads; and normalize(),
|
|
5
|
+
which turns that response into the document's plan, windows, details and, for a balance account,
|
|
6
|
+
balance. A provider may add
|
|
7
|
+
add_commands(sub) and run_command(args) for its own setup command, and selectors() with using(selector)
|
|
8
|
+
when one person can have several accounts on one machine, such as Claude's config directories.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import agy, claude, codex, cursor, deepseek, devin, gemini, opencode, zcode
|
|
12
|
+
|
|
13
|
+
PROVIDERS = {module.NAME: module for module in (agy, claude, codex, cursor, deepseek, devin, gemini, opencode, zcode)}
|