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 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())