tokenmaxxr 1.0.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.
- tokenmaxxr/__init__.py +20 -0
- tokenmaxxr/_webui/dist/app.js +160 -0
- tokenmaxxr/_webui/dist/index.html +33 -0
- tokenmaxxr/_webui/dist/styles.css +36 -0
- tokenmaxxr/cache.py +125 -0
- tokenmaxxr/cli.py +163 -0
- tokenmaxxr/cookies.py +278 -0
- tokenmaxxr/fetch.py +82 -0
- tokenmaxxr/history.py +62 -0
- tokenmaxxr/providers/__init__.py +21 -0
- tokenmaxxr/providers/base.py +65 -0
- tokenmaxxr/providers/clinepass.py +119 -0
- tokenmaxxr/providers/ollama_cloud.py +138 -0
- tokenmaxxr/providers/opencode_go.py +101 -0
- tokenmaxxr/py.typed +1 -0
- tokenmaxxr/reset.py +58 -0
- tokenmaxxr/schema.py +71 -0
- tokenmaxxr/serve.py +72 -0
- tokenmaxxr-1.0.0.dist-info/METADATA +148 -0
- tokenmaxxr-1.0.0.dist-info/RECORD +23 -0
- tokenmaxxr-1.0.0.dist-info/WHEEL +4 -0
- tokenmaxxr-1.0.0.dist-info/entry_points.txt +2 -0
- tokenmaxxr-1.0.0.dist-info/licenses/LICENSE +21 -0
tokenmaxxr/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""tokenmaxxr — aggregate AI subscription quota usage into one view."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = _pkg_version("tokenmaxxr")
|
|
7
|
+
except PackageNotFoundError: # running from raw checkout, not installed
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
9
|
+
|
|
10
|
+
from tokenmaxxr.schema import ProviderResult, UsageWindow, build_document, status_of
|
|
11
|
+
from tokenmaxxr.fetch import fetch_all
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ProviderResult",
|
|
15
|
+
"UsageWindow",
|
|
16
|
+
"build_document",
|
|
17
|
+
"fetch_all",
|
|
18
|
+
"status_of",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// tokenmaxxr WebUI — vanilla JS, no build step.
|
|
2
|
+
const $ = (sel) => document.querySelector(sel);
|
|
3
|
+
const cards = $("#cards");
|
|
4
|
+
const providerSel = $("#provider");
|
|
5
|
+
const windowSel = $("#window");
|
|
6
|
+
const chart = $("#chart");
|
|
7
|
+
const historySec = $("#history");
|
|
8
|
+
const auto = $("#auto");
|
|
9
|
+
let timer = null;
|
|
10
|
+
|
|
11
|
+
async function jget(url) {
|
|
12
|
+
const r = await fetch(url);
|
|
13
|
+
if (!r.ok) throw new Error(`${url}: ${r.status}`);
|
|
14
|
+
return r.json();
|
|
15
|
+
}
|
|
16
|
+
async function jpost(url) {
|
|
17
|
+
const r = await fetch(url, { method: "POST" });
|
|
18
|
+
if (!r.ok) throw new Error(`${url}: ${r.status}`);
|
|
19
|
+
return r.json();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fmtPct(n) {
|
|
23
|
+
return `${Math.max(0, Math.min(100, n || 0))}%`;
|
|
24
|
+
}
|
|
25
|
+
function fmtReset(iso) {
|
|
26
|
+
if (!iso) return "";
|
|
27
|
+
try {
|
|
28
|
+
const d = new Date(iso);
|
|
29
|
+
const diff = d - Date.now();
|
|
30
|
+
if (diff < 0) return "now";
|
|
31
|
+
const m = Math.floor(diff / 60000);
|
|
32
|
+
if (m < 60) return `${m}m`;
|
|
33
|
+
const h = Math.floor(m / 60);
|
|
34
|
+
if (h < 24) return `${h}h`;
|
|
35
|
+
return `${Math.floor(h / 24)}d`;
|
|
36
|
+
} catch { return ""; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderCard(p) {
|
|
40
|
+
const div = document.createElement("div");
|
|
41
|
+
div.className = "card";
|
|
42
|
+
const head = document.createElement("h2");
|
|
43
|
+
head.textContent = p.name;
|
|
44
|
+
const status = document.createElement("span");
|
|
45
|
+
status.className = `status ${p.status}`;
|
|
46
|
+
status.textContent = p.status;
|
|
47
|
+
head.appendChild(status);
|
|
48
|
+
div.appendChild(head);
|
|
49
|
+
if (p.error) {
|
|
50
|
+
const e = document.createElement("div");
|
|
51
|
+
e.className = "status error";
|
|
52
|
+
e.textContent = p.error;
|
|
53
|
+
div.appendChild(e);
|
|
54
|
+
}
|
|
55
|
+
for (const w of p.windows || []) {
|
|
56
|
+
const row = document.createElement("div");
|
|
57
|
+
row.className = "window";
|
|
58
|
+
const label = document.createElement("div");
|
|
59
|
+
label.className = "row";
|
|
60
|
+
const pct = w.pct_used || 0;
|
|
61
|
+
label.innerHTML = `<span>${w.label}</span><span>${fmtPct(pct)} · ${fmtReset(w.reset_at)}</span>`;
|
|
62
|
+
const bar = document.createElement("div");
|
|
63
|
+
const cls = pct >= 90 ? "err" : pct >= 70 ? "warn" : "";
|
|
64
|
+
bar.className = `bar ${cls}`;
|
|
65
|
+
bar.innerHTML = `<span style="width:${pct}%"></span>`;
|
|
66
|
+
row.appendChild(label);
|
|
67
|
+
row.appendChild(bar);
|
|
68
|
+
div.appendChild(row);
|
|
69
|
+
}
|
|
70
|
+
return div;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function loadUsage() {
|
|
74
|
+
const doc = await jget("/api/usage");
|
|
75
|
+
cards.innerHTML = "";
|
|
76
|
+
for (const p of doc.providers) cards.appendChild(renderCard(p));
|
|
77
|
+
// Populate provider filter
|
|
78
|
+
const existing = new Set([...providerSel.options].map(o => o.value));
|
|
79
|
+
for (const p of doc.providers) {
|
|
80
|
+
if (!existing.has(p.id)) {
|
|
81
|
+
const opt = document.createElement("option");
|
|
82
|
+
opt.value = p.id;
|
|
83
|
+
opt.textContent = p.name;
|
|
84
|
+
providerSel.appendChild(opt);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
historySec.hidden = false;
|
|
88
|
+
await loadHistory();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function loadHistory() {
|
|
92
|
+
const params = new URLSearchParams();
|
|
93
|
+
if (providerSel.value) params.set("provider", providerSel.value);
|
|
94
|
+
if (windowSel.value) params.set("window", windowSel.value);
|
|
95
|
+
const rows = await jget(`/api/history?${params}`);
|
|
96
|
+
drawChart(rows);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function drawChart(rows) {
|
|
100
|
+
const ctx = chart.getContext("2d");
|
|
101
|
+
ctx.clearRect(0, 0, chart.width, chart.height);
|
|
102
|
+
if (!rows.length) {
|
|
103
|
+
ctx.fillStyle = "#888";
|
|
104
|
+
ctx.fillText("no history yet", 12, 24);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Group by window
|
|
108
|
+
const byWin = new Map();
|
|
109
|
+
for (const r of rows) {
|
|
110
|
+
if (!byWin.has(r.window)) byWin.set(r.window, []);
|
|
111
|
+
byWin.get(r.window).push(r);
|
|
112
|
+
}
|
|
113
|
+
const padX = 40, padY = 10;
|
|
114
|
+
const w = chart.width - padX * 2;
|
|
115
|
+
const h = chart.height - padY * 2;
|
|
116
|
+
ctx.strokeStyle = "#888";
|
|
117
|
+
ctx.beginPath();
|
|
118
|
+
ctx.moveTo(padX, padY + h);
|
|
119
|
+
ctx.lineTo(padX + w, padY + h);
|
|
120
|
+
ctx.stroke();
|
|
121
|
+
const colors = { "5h": "#059669", "7d": "#2563eb", "30d": "#d97706" };
|
|
122
|
+
for (const [win, pts] of byWin) {
|
|
123
|
+
pts.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
124
|
+
ctx.strokeStyle = colors[win] || "#888";
|
|
125
|
+
ctx.beginPath();
|
|
126
|
+
pts.forEach((p, i) => {
|
|
127
|
+
const x = padX + (i / Math.max(1, pts.length - 1)) * w;
|
|
128
|
+
const y = padY + h - (p.pct_used / 100) * h;
|
|
129
|
+
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
|
130
|
+
});
|
|
131
|
+
ctx.stroke();
|
|
132
|
+
// legend
|
|
133
|
+
ctx.fillStyle = colors[win] || "#888";
|
|
134
|
+
ctx.fillText(win, padX + ([...byWin.keys()].indexOf(win)) * 30, padY + 12);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
$("#refresh").addEventListener("click", async () => {
|
|
139
|
+
$("#refresh").textContent = "refreshing…";
|
|
140
|
+
try {
|
|
141
|
+
await jpost("/api/refresh");
|
|
142
|
+
await loadUsage();
|
|
143
|
+
} finally {
|
|
144
|
+
$("#refresh").textContent = "refresh now";
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
providerSel.addEventListener("change", loadHistory);
|
|
148
|
+
windowSel.addEventListener("change", loadHistory);
|
|
149
|
+
auto.addEventListener("change", () => {
|
|
150
|
+
if (auto.checked) {
|
|
151
|
+
timer = setInterval(loadUsage, 60_000);
|
|
152
|
+
} else if (timer) {
|
|
153
|
+
clearInterval(timer);
|
|
154
|
+
timer = null;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
loadUsage().catch((e) => {
|
|
159
|
+
cards.textContent = `Error: ${e.message}`;
|
|
160
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
+
<title>tokenmaxxr</title>
|
|
7
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header>
|
|
11
|
+
<h1>tokenmaxxr</h1>
|
|
12
|
+
<div class="controls">
|
|
13
|
+
<label><input type="checkbox" id="auto" /> auto-refresh</label>
|
|
14
|
+
<button id="refresh">refresh now</button>
|
|
15
|
+
</div>
|
|
16
|
+
</header>
|
|
17
|
+
<main id="cards"></main>
|
|
18
|
+
<section id="history" hidden>
|
|
19
|
+
<h2>history</h2>
|
|
20
|
+
<select id="provider">
|
|
21
|
+
<option value="">all providers</option>
|
|
22
|
+
</select>
|
|
23
|
+
<select id="window">
|
|
24
|
+
<option value="">all windows</option>
|
|
25
|
+
<option>5h</option>
|
|
26
|
+
<option>7d</option>
|
|
27
|
+
<option>30d</option>
|
|
28
|
+
</select>
|
|
29
|
+
<canvas id="chart" width="600" height="160"></canvas>
|
|
30
|
+
</section>
|
|
31
|
+
<script type="module" src="./app.js"></script>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
--bg: #fff;
|
|
4
|
+
--fg: #111;
|
|
5
|
+
--muted: #888;
|
|
6
|
+
--card: #f5f5f7;
|
|
7
|
+
--warn: #d97706;
|
|
8
|
+
--err: #dc2626;
|
|
9
|
+
--ok: #059669;
|
|
10
|
+
}
|
|
11
|
+
@media (prefers-color-scheme: dark) {
|
|
12
|
+
:root { --bg:#0b0b0c; --fg:#eee; --muted:#888; --card:#1a1a1d; }
|
|
13
|
+
}
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: var(--bg); color: var(--fg); }
|
|
16
|
+
header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; border-bottom: 1px solid var(--muted); }
|
|
17
|
+
h1 { font-size: 18px; margin: 0; }
|
|
18
|
+
.controls { display: flex; gap: 12px; align-items: center; }
|
|
19
|
+
button { background: var(--card); color: var(--fg); border: 1px solid var(--muted); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
|
|
20
|
+
main { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); padding: 20px; }
|
|
21
|
+
.card { background: var(--card); border-radius: 8px; padding: 14px; }
|
|
22
|
+
.card h2 { font-size: 14px; margin: 0 0 6px; display: flex; justify-content: space-between; }
|
|
23
|
+
.card .status { font-size: 12px; color: var(--muted); }
|
|
24
|
+
.card .status.error { color: var(--err); }
|
|
25
|
+
.card .status.no-auth { color: var(--warn); }
|
|
26
|
+
.card .status.stale { color: var(--warn); }
|
|
27
|
+
.window { margin: 8px 0; }
|
|
28
|
+
.window .row { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); }
|
|
29
|
+
.bar { background: var(--bg); border-radius: 4px; height: 6px; overflow: hidden; margin: 4px 0; }
|
|
30
|
+
.bar > span { display: block; height: 100%; background: var(--ok); }
|
|
31
|
+
.bar.warn > span { background: var(--warn); }
|
|
32
|
+
.bar.err > span { background: var(--err); }
|
|
33
|
+
#history { padding: 0 20px 24px; }
|
|
34
|
+
#history h2 { font-size: 14px; }
|
|
35
|
+
#history select { margin-right: 8px; }
|
|
36
|
+
canvas { background: var(--card); border-radius: 8px; padding: 8px; max-width: 100%; }
|
tokenmaxxr/cache.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Per-provider TTL cache.
|
|
2
|
+
|
|
3
|
+
Backed by JSON files under $XDG_STATE_HOME/tokenmaxxr/cache/<slug>.json
|
|
4
|
+
(defaults to ~/.local/state/tokenmaxxr/cache). Each entry stores
|
|
5
|
+
{fetched_at: iso, payload: {...normalized provider result...}}.
|
|
6
|
+
|
|
7
|
+
`--refresh` is a cache invalidation flag handled by the caller
|
|
8
|
+
(tokenmaxxr.fetch), not by this module.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import asdict
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from tokenmaxxr.schema import ProviderResult, UsageWindow
|
|
21
|
+
|
|
22
|
+
DEFAULT_TTL_SECONDS = 300
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def state_dir() -> Path:
|
|
26
|
+
base = os.environ.get("XDG_STATE_HOME") or os.path.expanduser(
|
|
27
|
+
"~/.local/state"
|
|
28
|
+
)
|
|
29
|
+
return Path(base) / "tokenmaxxr"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cache_dir() -> Path:
|
|
33
|
+
d = state_dir() / "cache"
|
|
34
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return d
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def history_path() -> Path:
|
|
39
|
+
d = state_dir()
|
|
40
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return d / "history.jsonl"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _path_for(slug: str) -> Path:
|
|
45
|
+
safe = slug.replace("/", "_")
|
|
46
|
+
return cache_dir() / f"{safe}.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read(slug: str, *, ttl: int = DEFAULT_TTL_SECONDS) -> ProviderResult | None:
|
|
50
|
+
"""Return a fresh cached result, or None on miss / stale."""
|
|
51
|
+
path = _path_for(slug)
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(path.read_text())
|
|
56
|
+
except (OSError, json.JSONDecodeError):
|
|
57
|
+
return None
|
|
58
|
+
fetched_at = data.get("fetched_at")
|
|
59
|
+
payload = data.get("payload")
|
|
60
|
+
if not isinstance(fetched_at, str) or not isinstance(payload, dict):
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
ts = datetime.fromisoformat(fetched_at.replace("Z", "+00:00"))
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
age = (datetime.now(timezone.utc) - ts).total_seconds()
|
|
67
|
+
if age > ttl:
|
|
68
|
+
return None
|
|
69
|
+
return _payload_to_result(slug, payload, status="ok")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_any_age(slug: str) -> ProviderResult | None:
|
|
73
|
+
"""Return the cached result regardless of age (for stale fallback)."""
|
|
74
|
+
path = _path_for(slug)
|
|
75
|
+
if not path.exists():
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(path.read_text())
|
|
79
|
+
except (OSError, json.JSONDecodeError):
|
|
80
|
+
return None
|
|
81
|
+
payload = data.get("payload")
|
|
82
|
+
if not isinstance(payload, dict):
|
|
83
|
+
return None
|
|
84
|
+
return _payload_to_result(slug, payload, status="stale")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write(slug: str, result: ProviderResult) -> None:
|
|
88
|
+
path = _path_for(slug)
|
|
89
|
+
payload = {
|
|
90
|
+
"id": result.id,
|
|
91
|
+
"name": result.name,
|
|
92
|
+
"windows": [asdict(w) for w in result.windows],
|
|
93
|
+
}
|
|
94
|
+
data = {
|
|
95
|
+
"fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
96
|
+
"payload": payload,
|
|
97
|
+
}
|
|
98
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
99
|
+
tmp.write_text(json.dumps(data))
|
|
100
|
+
tmp.replace(path)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def invalidate(slug: str) -> None:
|
|
104
|
+
path = _path_for(slug)
|
|
105
|
+
if path.exists():
|
|
106
|
+
path.unlink()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _payload_to_result(
|
|
110
|
+
slug: str, payload: dict, *, status: str
|
|
111
|
+
) -> ProviderResult:
|
|
112
|
+
return ProviderResult(
|
|
113
|
+
id=payload.get("id", slug),
|
|
114
|
+
name=payload.get("name", slug),
|
|
115
|
+
status=status, # type: ignore[arg-type]
|
|
116
|
+
windows=[
|
|
117
|
+
UsageWindow(
|
|
118
|
+
label=w["label"],
|
|
119
|
+
pct_used=int(w["pct_used"]),
|
|
120
|
+
reset_at=w.get("reset_at"),
|
|
121
|
+
)
|
|
122
|
+
for w in payload.get("windows", [])
|
|
123
|
+
if isinstance(w, dict) and "label" in w and "pct_used" in w
|
|
124
|
+
],
|
|
125
|
+
)
|
tokenmaxxr/cli.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""`tokenmaxxr` CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
from tokenmaxxr import __version__
|
|
11
|
+
from tokenmaxxr import cookies as _cookies
|
|
12
|
+
from tokenmaxxr import fetch_all
|
|
13
|
+
from tokenmaxxr.cookies import discover_sources
|
|
14
|
+
from tokenmaxxr.providers import get_provider, known_slugs
|
|
15
|
+
from tokenmaxxr.schema import dumps
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
19
|
+
parser = _build_parser()
|
|
20
|
+
args = parser.parse_args(argv)
|
|
21
|
+
|
|
22
|
+
if args.command == "doctor":
|
|
23
|
+
return _cmd_doctor(args)
|
|
24
|
+
if args.command == "serve":
|
|
25
|
+
return _cmd_serve(args)
|
|
26
|
+
|
|
27
|
+
overrides = _parse_cookie_overrides(getattr(args, "cookie_file", []) or [])
|
|
28
|
+
try:
|
|
29
|
+
doc = fetch_all(
|
|
30
|
+
providers=getattr(args, "provider", None),
|
|
31
|
+
cookie_overrides=overrides or None,
|
|
32
|
+
refresh=getattr(args, "refresh", False),
|
|
33
|
+
)
|
|
34
|
+
except FileNotFoundError as e:
|
|
35
|
+
sys.stderr.write(f"tokenmaxxr: {e}\n")
|
|
36
|
+
return 2
|
|
37
|
+
|
|
38
|
+
if args.json:
|
|
39
|
+
sys.stdout.write(dumps(doc) + "\n")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
_print_table(doc)
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
p = argparse.ArgumentParser(
|
|
48
|
+
prog="tokenmaxxr",
|
|
49
|
+
description="Aggregate AI subscription quota usage.",
|
|
50
|
+
)
|
|
51
|
+
p.add_argument(
|
|
52
|
+
"--version", action="version", version=f"tokenmaxxr {__version__}"
|
|
53
|
+
)
|
|
54
|
+
p.add_argument(
|
|
55
|
+
"--json", action="store_true", help="emit one JSON document on stdout"
|
|
56
|
+
)
|
|
57
|
+
p.add_argument(
|
|
58
|
+
"--provider",
|
|
59
|
+
action="append",
|
|
60
|
+
default=None,
|
|
61
|
+
help="limit to provider slug (repeatable)",
|
|
62
|
+
)
|
|
63
|
+
p.add_argument(
|
|
64
|
+
"--refresh",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="ignore the cache and re-fetch live",
|
|
67
|
+
)
|
|
68
|
+
p.add_argument(
|
|
69
|
+
"--cookie-file",
|
|
70
|
+
action="append",
|
|
71
|
+
default=[],
|
|
72
|
+
metavar="SLUG=PATH",
|
|
73
|
+
help="override cookie store path (repeatable)",
|
|
74
|
+
)
|
|
75
|
+
sub = p.add_subparsers(dest="command", required=False)
|
|
76
|
+
|
|
77
|
+
sub.add_parser("doctor", help="show which cookie stores could be used per provider")
|
|
78
|
+
|
|
79
|
+
serve = sub.add_parser("serve", help="run the local web dashboard")
|
|
80
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
81
|
+
serve.add_argument("--port", type=int, default=8765)
|
|
82
|
+
|
|
83
|
+
return p
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_cookie_overrides(items: list[str]) -> dict[str, str]:
|
|
87
|
+
out: dict[str, str] = {}
|
|
88
|
+
for item in items:
|
|
89
|
+
if "=" not in item:
|
|
90
|
+
raise SystemExit(f"--cookie-file expects SLUG=PATH, got: {item!r}")
|
|
91
|
+
k, v = item.split("=", 1)
|
|
92
|
+
out[k.strip()] = v.strip()
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _print_table(doc: dict) -> None:
|
|
97
|
+
rows: list[tuple[str, str, str, str, str]] = []
|
|
98
|
+
for prov in doc.get("providers", []):
|
|
99
|
+
name = prov.get("name") or prov.get("id") or "?"
|
|
100
|
+
status = prov.get("status", "?")
|
|
101
|
+
windows = prov.get("windows") or []
|
|
102
|
+
if windows:
|
|
103
|
+
label = ", ".join(
|
|
104
|
+
f"{w.get('label','?')}:{w.get('pct_used',0)}%" for w in windows
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
label = "(no windows)"
|
|
108
|
+
reset = ""
|
|
109
|
+
if windows:
|
|
110
|
+
# Pick the first window that actually has a reset time. The
|
|
111
|
+
# "primary" window may legitimately be None (e.g. 5h on
|
|
112
|
+
# ClinePass has no resetsAt), so fall through to the next
|
|
113
|
+
# window instead of always taking the first.
|
|
114
|
+
for w in windows:
|
|
115
|
+
ts = w.get("reset_at")
|
|
116
|
+
if ts:
|
|
117
|
+
reset = ts
|
|
118
|
+
break
|
|
119
|
+
rows.append((name, status, label, reset, prov.get("error") or ""))
|
|
120
|
+
widths = [max(len(str(r[i])) for r in rows + [("",)*5]) for i in range(5)]
|
|
121
|
+
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
122
|
+
print(fmt.format("PROVIDER", "STATUS", "USAGE", "RESET", "ERROR"))
|
|
123
|
+
for r in rows:
|
|
124
|
+
print(fmt.format(*r))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _cmd_doctor(args: argparse.Namespace) -> int:
|
|
128
|
+
# Use the module reference so monkeypatching `cookies.discover_sources` is
|
|
129
|
+
# picked up here (it would be missed if we imported the name directly).
|
|
130
|
+
lines: list[str] = []
|
|
131
|
+
for slug in known_slugs():
|
|
132
|
+
prov = get_provider(slug)
|
|
133
|
+
sources = _cookies.discover_sources(prov.cookie_domain)
|
|
134
|
+
lines.append(f"# {prov.name} ({slug}) — domain: {prov.cookie_domain}")
|
|
135
|
+
if not sources:
|
|
136
|
+
lines.append(" no cookie stores discovered")
|
|
137
|
+
lines.append(
|
|
138
|
+
f" remediation: log in to {prov.cookie_domain} in your browser, "
|
|
139
|
+
"or pass --cookie-file SLUG=/path/to/cookies.sqlite"
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
for s in sources:
|
|
143
|
+
lines.append(f" - {s.kind}: {s.detail}")
|
|
144
|
+
lines.append("")
|
|
145
|
+
sys.stdout.write("\n".join(lines))
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
150
|
+
try:
|
|
151
|
+
from tokenmaxxr.serve import run
|
|
152
|
+
except ImportError as e:
|
|
153
|
+
sys.stderr.write(
|
|
154
|
+
"serve requires the [serve] extra: pip install 'tokenmaxxr[serve]'\n"
|
|
155
|
+
f" ({e})"
|
|
156
|
+
)
|
|
157
|
+
return 2
|
|
158
|
+
run(host=getattr(args, "host", "127.0.0.1"), port=getattr(args, "port", 8765))
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
raise SystemExit(main())
|