@emesoft/tokenhub 0.1.0 → 0.3.0
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.
- package/README.md +12 -2
- package/bin/tokenhub.js +35 -1
- package/lib/dashboard.d.ts +34 -0
- package/lib/dashboard.js +379 -0
- package/lib/usage.d.ts +7 -0
- package/lib/usage.js +111 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
# @emesoft/tokenhub
|
|
2
2
|
|
|
3
|
-
Pair Claude Code on this machine with a TokenHub pool. No dependencies, Node 18+.
|
|
3
|
+
Pair Claude Code on this machine with a [TokenHub](https://tokenhub.aimpact.io.vn) pool. No dependencies, Node 18+.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
npx @emesoft/tokenhub connect https://
|
|
6
|
+
npx @emesoft/tokenhub connect https://tokenhub.aimpact.io.vn
|
|
7
|
+
npx @emesoft/tokenhub usage
|
|
8
|
+
npx @emesoft/tokenhub dashboard
|
|
7
9
|
npx @emesoft/tokenhub disconnect
|
|
8
10
|
```
|
|
9
11
|
|
|
10
12
|
`connect` prints a short code and opens your browser. Confirm the code on the pool's member site and the CLI writes `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` into `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`). Other settings are kept, and the previous file is saved as `settings.json.bak`. Each paired machine gets its own key, which you can revoke on the Keys page.
|
|
11
13
|
|
|
14
|
+
`usage` shows today's and this week's spend against your limits, your credit and the pool's health (`--json` for the raw report). It exits 2 when the pool would currently refuse your requests (out of credit or budget reached).
|
|
15
|
+
|
|
16
|
+
`dashboard` is a live full-screen view: today's and this week's spend as bars against your limits (green under 70%, orange to 90%, red above), credit, pool health, a banner when you are blocked, and a table of your recent requests, newest first. It refreshes every 3 seconds (`--interval <sec>`, minimum 2). Press `q` or Ctrl+C to quit and `r` to refresh now. On a connection error it keeps the last good screen, shows `reconnecting…` and backs off (honouring `retry-after`). Without a terminal, with `NO_COLOR` set or with `--once`, it prints one plain frame and exits (code 2 when blocked, like `usage`).
|
|
17
|
+
|
|
18
|
+
Requests to the pool carry your key and one extra header, `x-tokenhub-cli: <version>` (this CLI's version), and nothing else.
|
|
19
|
+
|
|
12
20
|
`disconnect` removes only those two entries.
|
|
13
21
|
|
|
14
22
|
If `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` is set in your shell, it overrides `settings.json`.
|
|
23
|
+
|
|
24
|
+
Sign in, manage your keys and top up at [tokenhub.aimpact.io.vn](https://tokenhub.aimpact.io.vn). The setup guide there has this command with your pool address filled in.
|
package/bin/tokenhub.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parseDashboardArgs, runDashboard } from "../lib/dashboard.js";
|
|
3
4
|
import { runConnect } from "../lib/pair.js";
|
|
4
|
-
import { disconnectSettings, settingsPath } from "../lib/settings.js";
|
|
5
|
+
import { disconnectSettings, readSettings, settingsPath } from "../lib/settings.js";
|
|
6
|
+
import { fetchUsage, formatUsage, resolveCredentials } from "../lib/usage.js";
|
|
5
7
|
|
|
6
8
|
const HELP = `tokenhub - pair Claude Code with a TokenHub pool
|
|
7
9
|
|
|
8
10
|
Usage:
|
|
9
11
|
tokenhub connect <poolUrl> Pair this machine and write ~/.claude/settings.json
|
|
12
|
+
tokenhub usage [--json] Show today's and this week's spend, credit and pool health
|
|
13
|
+
tokenhub dashboard [--interval <sec>] [--once]
|
|
14
|
+
Live full-screen view of spend, pool health and recent requests (q quits)
|
|
10
15
|
tokenhub disconnect Remove the pool entries from settings.json
|
|
11
16
|
tokenhub --help | --version
|
|
12
17
|
`;
|
|
@@ -37,6 +42,35 @@ async function main(argv) {
|
|
|
37
42
|
process.once("SIGINT", () => controller.abort());
|
|
38
43
|
return runConnect({ poolUrl: arg, settingsFile: file, signal: controller.signal });
|
|
39
44
|
}
|
|
45
|
+
if (command === "usage") {
|
|
46
|
+
try {
|
|
47
|
+
const credentials = resolveCredentials(readSettings(file).settings);
|
|
48
|
+
if (!credentials) {
|
|
49
|
+
console.error("Not connected. Run `tokenhub connect <poolUrl>` first.");
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
const report = await fetchUsage(credentials.baseUrl, credentials.key);
|
|
53
|
+
console.log(arg === "--json" ? JSON.stringify(report, null, 2) : formatUsage(report));
|
|
54
|
+
return report.blocked ? 2 : 0;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error(`Error: ${error.message}`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (command === "dashboard") {
|
|
61
|
+
try {
|
|
62
|
+
const options = parseDashboardArgs(argv.slice(1));
|
|
63
|
+
const credentials = resolveCredentials(readSettings(file).settings);
|
|
64
|
+
if (!credentials) {
|
|
65
|
+
console.error("Not connected. Run `tokenhub connect <poolUrl>` first.");
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
return await runDashboard(credentials, options);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`Error: ${error.message}`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
40
74
|
if (command === "disconnect") {
|
|
41
75
|
try {
|
|
42
76
|
console.log(disconnectSettings(file) ? `Disconnected. Updated ${file}` : `Nothing to remove in ${file}`);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface DashboardState {
|
|
2
|
+
report: Record<string, any> | null;
|
|
3
|
+
recent: Record<string, any>[];
|
|
4
|
+
refreshedAt: number | null;
|
|
5
|
+
error: string | null;
|
|
6
|
+
intervalSec: number;
|
|
7
|
+
poolHost?: string;
|
|
8
|
+
timeZone?: string;
|
|
9
|
+
}
|
|
10
|
+
export const DEFAULT_INTERVAL_SEC: number;
|
|
11
|
+
export const MIN_INTERVAL_SEC: number;
|
|
12
|
+
export function parseDashboardArgs(args: string[]): { intervalSec: number; once: boolean };
|
|
13
|
+
export function renderFrame(state: DashboardState, view: { columns: number; rows: number; color: boolean; now: number; fill?: boolean }): string;
|
|
14
|
+
export function startPolling(
|
|
15
|
+
credentials: { baseUrl: string; key: string },
|
|
16
|
+
options: {
|
|
17
|
+
intervalMs: number;
|
|
18
|
+
getLimit: () => number;
|
|
19
|
+
onState: (state: DashboardState) => void;
|
|
20
|
+
signal: AbortSignal;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
timeZone?: string;
|
|
23
|
+
},
|
|
24
|
+
): { done: Promise<void>; refreshNow: () => void };
|
|
25
|
+
export function runDashboard(
|
|
26
|
+
credentials: { baseUrl: string; key: string },
|
|
27
|
+
options: { intervalSec: number; once: boolean },
|
|
28
|
+
io?: {
|
|
29
|
+
stdout?: NodeJS.WriteStream;
|
|
30
|
+
stdin?: NodeJS.ReadStream;
|
|
31
|
+
env?: Record<string, string | undefined>;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
},
|
|
34
|
+
): Promise<number>;
|
package/lib/dashboard.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { fetchRecent, fetchUsage } from "./usage.js";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_INTERVAL_SEC = 3;
|
|
4
|
+
export const MIN_INTERVAL_SEC = 2;
|
|
5
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
6
|
+
const BAR_WIDTH = 20;
|
|
7
|
+
const ENTER_SCREEN = "\x1b[?1049h\x1b[?25l";
|
|
8
|
+
const LEAVE_SCREEN = "\x1b[?25h\x1b[?1049l";
|
|
9
|
+
const COLORS = { green: "32", clay: "38;5;173", red: "31", grey: "90", bold: "1" };
|
|
10
|
+
const ANSI = /\x1b\[[0-9;]*m/y;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse the arguments after `dashboard`.
|
|
14
|
+
* @param {string[]} args Raw arguments.
|
|
15
|
+
* @returns {{ intervalSec: number, once: boolean }} Validated options.
|
|
16
|
+
* @throws {Error} On an unknown flag or an interval that is not a number of at least MIN_INTERVAL_SEC.
|
|
17
|
+
*/
|
|
18
|
+
export function parseDashboardArgs(args) {
|
|
19
|
+
let intervalSec = DEFAULT_INTERVAL_SEC;
|
|
20
|
+
let once = false;
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
if (args[i] === "--once") once = true;
|
|
23
|
+
else if (args[i] === "--interval") {
|
|
24
|
+
intervalSec = Number(args[++i]);
|
|
25
|
+
if (!Number.isFinite(intervalSec) || intervalSec < MIN_INTERVAL_SEC) {
|
|
26
|
+
throw new Error(`--interval must be a number of seconds, ${MIN_INTERVAL_SEC} or more.`);
|
|
27
|
+
}
|
|
28
|
+
} else throw new Error(`Unknown option: ${args[i]}`);
|
|
29
|
+
}
|
|
30
|
+
return { intervalSec, once };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Wrap text in an SGR colour when colour is on.
|
|
35
|
+
* @param {boolean} color Whether to emit escape codes.
|
|
36
|
+
* @param {keyof typeof COLORS} name Colour name.
|
|
37
|
+
* @param {string} text Text to paint.
|
|
38
|
+
* @returns {string} The text, painted or untouched.
|
|
39
|
+
*/
|
|
40
|
+
function paint(color, name, text) {
|
|
41
|
+
return color ? `\x1b[${COLORS[name]}m${text}\x1b[0m` : text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shorten text to a width, marking the cut with an ellipsis.
|
|
46
|
+
* @param {string} text Plain text.
|
|
47
|
+
* @param {number} width Maximum visible width.
|
|
48
|
+
* @returns {string} Text of at most `width` characters.
|
|
49
|
+
*/
|
|
50
|
+
function truncate(text, width) {
|
|
51
|
+
if (text.length <= width) return text;
|
|
52
|
+
return width <= 1 ? text.slice(0, Math.max(width, 0)) : `${text.slice(0, width - 1)}…`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Cut a line that may contain SGR codes to a visible width so it never wraps.
|
|
57
|
+
* @param {string} line Line with optional colour codes.
|
|
58
|
+
* @param {number} columns Maximum visible width.
|
|
59
|
+
* @returns {string} The line, closed with a reset when it was cut.
|
|
60
|
+
*/
|
|
61
|
+
function clip(line, columns) {
|
|
62
|
+
let out = "";
|
|
63
|
+
let visible = 0;
|
|
64
|
+
let i = 0;
|
|
65
|
+
while (i < line.length) {
|
|
66
|
+
ANSI.lastIndex = i;
|
|
67
|
+
const code = ANSI.exec(line);
|
|
68
|
+
if (code) {
|
|
69
|
+
out += code[0];
|
|
70
|
+
i += code[0].length;
|
|
71
|
+
} else if (visible < columns) {
|
|
72
|
+
out += line[i++];
|
|
73
|
+
visible++;
|
|
74
|
+
} else return `${out}\x1b[0m`;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format a dollar amount.
|
|
81
|
+
* @param {number} amount USD.
|
|
82
|
+
* @returns {string} For example `$1.20`, or `$0.0042` for amounts under a cent.
|
|
83
|
+
*/
|
|
84
|
+
function money(amount) {
|
|
85
|
+
const n = Number(amount) || 0;
|
|
86
|
+
return n > 0 && n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format a token count compactly.
|
|
91
|
+
* @param {number | null | undefined} count Tokens.
|
|
92
|
+
* @returns {string} For example `842`, `12.3k` or `1.2M`; `-` when unknown.
|
|
93
|
+
*/
|
|
94
|
+
function tokens(count) {
|
|
95
|
+
if (count === null || count === undefined) return "-";
|
|
96
|
+
if (count >= 1e6) return `${(count / 1e6).toFixed(1)}M`;
|
|
97
|
+
if (count >= 1e4) return `${(count / 1e3).toFixed(1)}k`;
|
|
98
|
+
return String(count);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Format a latency.
|
|
103
|
+
* @param {number | null | undefined} ms Milliseconds.
|
|
104
|
+
* @returns {string} For example `340ms` or `2.4s`; `-` when unknown.
|
|
105
|
+
*/
|
|
106
|
+
function latency(ms) {
|
|
107
|
+
if (ms === null || ms === undefined) return "-";
|
|
108
|
+
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Format a time of day as HH:MM:SS.
|
|
113
|
+
* @param {string | number} when ISO string or epoch milliseconds.
|
|
114
|
+
* @param {string | undefined} timeZone IANA zone (system default when omitted).
|
|
115
|
+
* @returns {string} Time of day.
|
|
116
|
+
*/
|
|
117
|
+
function clock(when, timeZone) {
|
|
118
|
+
return new Date(when).toLocaleTimeString("en-GB", { timeZone, hour12: false });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Draw one spend window as a bar against its limit.
|
|
123
|
+
* @param {string} label Row label.
|
|
124
|
+
* @param {{ spent_usd: number, limit_usd: number | null }} window Spend and limit.
|
|
125
|
+
* @param {number} barWidth Cells in the bar.
|
|
126
|
+
* @param {boolean} color Whether to emit colour.
|
|
127
|
+
* @returns {string} One line.
|
|
128
|
+
*/
|
|
129
|
+
function spendLine(label, window, barWidth, color) {
|
|
130
|
+
const head = label.padEnd(8);
|
|
131
|
+
if (window.limit_usd === null || window.limit_usd === undefined) {
|
|
132
|
+
return `${head}${paint(color, "grey", "░".repeat(barWidth))} ${money(window.spent_usd)} ${paint(color, "grey", "no limit")}`;
|
|
133
|
+
}
|
|
134
|
+
const ratio = window.limit_usd > 0 ? window.spent_usd / window.limit_usd : 1;
|
|
135
|
+
const tone = ratio > 0.9 ? "red" : ratio >= 0.7 ? "clay" : "green";
|
|
136
|
+
const filled = Math.round(Math.min(ratio, 1) * barWidth);
|
|
137
|
+
const bar = paint(color, tone, "█".repeat(filled)) + paint(color, "grey", "░".repeat(barWidth - filled));
|
|
138
|
+
return `${head}${bar} ${money(window.spent_usd)} / ${money(window.limit_usd)} ${paint(color, tone, `${Math.round(ratio * 100)}%`)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Explain a block in one line for the banner.
|
|
143
|
+
* @param {Record<string, any>} report Status report with `blocked` set.
|
|
144
|
+
* @param {string | undefined} timeZone IANA zone for the lift time.
|
|
145
|
+
* @returns {string} Banner text.
|
|
146
|
+
*/
|
|
147
|
+
function blockedText(report, timeZone) {
|
|
148
|
+
if (report.blocked_reason === "credit") return "BLOCKED: out of credit. Top up on the member site.";
|
|
149
|
+
const period = report.blocked_reason === "weekly_budget" ? "weekly" : "daily";
|
|
150
|
+
const lifts = report.blocked_until ? `, lifts ${new Date(report.blocked_until).toLocaleString("en-GB", { timeZone, dateStyle: "medium", timeStyle: "short" })}` : "";
|
|
151
|
+
return `BLOCKED: ${period} budget reached${lifts}.`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Pick the optional table columns that fit the terminal width.
|
|
156
|
+
* @param {number} columns Terminal width.
|
|
157
|
+
* @returns {{ latency: boolean, tokens: boolean }} Which optional columns to show.
|
|
158
|
+
*/
|
|
159
|
+
function tableLayout(columns) {
|
|
160
|
+
return { latency: columns >= 50, tokens: columns >= 70 };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Colour name for an HTTP status.
|
|
165
|
+
* @param {number} status HTTP status of a logged request.
|
|
166
|
+
* @returns {keyof typeof COLORS} Green below 400, clay for 4xx, red otherwise.
|
|
167
|
+
*/
|
|
168
|
+
function statusTone(status) {
|
|
169
|
+
return status < 400 ? "green" : status < 500 ? "clay" : "red";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render one request as a table row.
|
|
174
|
+
* @param {Record<string, any>} request Entry from `/member/api/recent`.
|
|
175
|
+
* @param {{ latency: boolean, tokens: boolean }} layout Visible columns.
|
|
176
|
+
* @param {number} modelWidth Width reserved for the model name.
|
|
177
|
+
* @param {boolean} color Whether to emit colour.
|
|
178
|
+
* @param {string | undefined} timeZone IANA zone.
|
|
179
|
+
* @returns {string} One line.
|
|
180
|
+
*/
|
|
181
|
+
function requestRow(request, layout, modelWidth, color, timeZone) {
|
|
182
|
+
const cells = [clock(request.started_at, timeZone), truncate(String(request.model ?? "-"), modelWidth).padEnd(modelWidth)];
|
|
183
|
+
cells.push(paint(color, statusTone(request.status), String(request.status).padEnd(6)));
|
|
184
|
+
if (layout.latency) cells.push(latency(request.latency_ms).padStart(7));
|
|
185
|
+
if (layout.tokens) cells.push(tokens(request.input_tokens).padStart(7), tokens(request.output_tokens).padStart(7));
|
|
186
|
+
cells.push(money(request.usd).padStart(8));
|
|
187
|
+
return cells.join(" ");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Render the whole dashboard as text. Pure: no I/O, so it can be tested without a terminal.
|
|
192
|
+
* @param {{ report: Record<string, any> | null, recent: Record<string, any>[], refreshedAt: number | null, error: string | null, intervalSec: number, poolHost?: string, timeZone?: string }} state Latest poll result.
|
|
193
|
+
* @param {{ columns: number, rows: number, color: boolean, now: number, fill?: boolean }} view Terminal size, colour switch, current epoch ms and whether to pad to `rows` so the footer sits on the last row (default true).
|
|
194
|
+
* @returns {string} Lines joined with `\n`, each at most `columns` wide, at most `rows` lines, no trailing newline.
|
|
195
|
+
*/
|
|
196
|
+
export function renderFrame(state, { columns, rows, color, now, fill = true }) {
|
|
197
|
+
const { report, recent, refreshedAt, error, intervalSec, timeZone } = state;
|
|
198
|
+
const lines = [];
|
|
199
|
+
const stale = refreshedAt !== null && now - refreshedAt > intervalSec * 2000 + 1000;
|
|
200
|
+
const refreshed = refreshedAt === null ? "waiting for first refresh" : `refreshed ${clock(refreshedAt, timeZone)}${stale ? " (stale)" : ""}`;
|
|
201
|
+
const title = `${report ? report.member : "TokenHub"}${state.poolHost ? ` @ ${state.poolHost}` : ""}`;
|
|
202
|
+
const right = `${refreshed} · every ${intervalSec}s`;
|
|
203
|
+
lines.push(`${paint(color, "bold", title)}${" ".repeat(Math.max(2, columns - title.length - right.length))}${paint(color, "grey", right)}`);
|
|
204
|
+
lines.push("");
|
|
205
|
+
|
|
206
|
+
if (report) {
|
|
207
|
+
const barWidth = Math.max(6, Math.min(BAR_WIDTH, columns - 40));
|
|
208
|
+
lines.push(spendLine("Today", report.today, barWidth, color), spendLine("Week", report.week, barWidth, color));
|
|
209
|
+
const poolState = report.pool?.state ?? "unknown";
|
|
210
|
+
const poolTone = poolState === "ok" ? "green" : poolState === "degraded" ? "clay" : poolState === "down" ? "red" : "grey";
|
|
211
|
+
lines.push(`${"Credit".padEnd(8)}${money(report.credit_usd)} Pool ${paint(color, poolTone, poolState)}`);
|
|
212
|
+
if (report.blocked) lines.push(paint(color, "red", blockedText(report, timeZone)));
|
|
213
|
+
} else {
|
|
214
|
+
lines.push(paint(color, "grey", "No data yet."));
|
|
215
|
+
}
|
|
216
|
+
lines.push("");
|
|
217
|
+
|
|
218
|
+
const footer = error ? paint(color, "red", `reconnecting… ${error}`) : paint(color, "grey", "q quit · r refresh now");
|
|
219
|
+
const layout = tableLayout(columns);
|
|
220
|
+
const fixed = 8 + 1 + 6 + 1 + 8 + (layout.latency ? 8 : 0) + (layout.tokens ? 16 : 0) + 1;
|
|
221
|
+
const modelWidth = Math.max(8, columns - fixed);
|
|
222
|
+
const header = ["Time".padEnd(8), "Model".padEnd(modelWidth), "Status".padEnd(6)];
|
|
223
|
+
if (layout.latency) header.push("Latency".padStart(7));
|
|
224
|
+
if (layout.tokens) header.push("In".padStart(7), "Out".padStart(7));
|
|
225
|
+
header.push("$".padStart(8));
|
|
226
|
+
lines.push(paint(color, "grey", header.join(" ")));
|
|
227
|
+
const room = Math.max(0, rows - lines.length - 1);
|
|
228
|
+
if (recent.length === 0) {
|
|
229
|
+
if (room > 0) lines.push(paint(color, "grey", "No requests yet."));
|
|
230
|
+
} else {
|
|
231
|
+
for (const request of recent.slice(0, room)) lines.push(requestRow(request, layout, modelWidth, color, timeZone));
|
|
232
|
+
}
|
|
233
|
+
while (fill && lines.length < rows - 1) lines.push("");
|
|
234
|
+
lines.push(footer);
|
|
235
|
+
return lines.slice(-Math.max(rows, 1)).map((line) => clip(line, columns)).join("\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Poll status and recent requests until aborted, keeping the last good data when a poll fails.
|
|
240
|
+
* Failures back off exponentially; a 429 waits at least its `retry-after`.
|
|
241
|
+
* @param {{ baseUrl: string, key: string }} credentials Pool and key.
|
|
242
|
+
* @param {{ intervalMs: number, getLimit: () => number, onState: (state: Record<string, any>) => void, signal: AbortSignal, fetchImpl?: typeof fetch, timeZone?: string }} options Poll settings; `onState` receives a fresh state object after every poll.
|
|
243
|
+
* @returns {{ done: Promise<void>, refreshNow: () => void }} `done` settles after abort; `refreshNow` cuts the current wait short.
|
|
244
|
+
*/
|
|
245
|
+
export function startPolling(credentials, { intervalMs, getLimit, onState, signal, fetchImpl = fetch, timeZone }) {
|
|
246
|
+
let state = { report: null, recent: [], refreshedAt: null, error: null, intervalSec: intervalMs / 1000, poolHost: new URL(credentials.baseUrl).host, timeZone };
|
|
247
|
+
let failures = 0;
|
|
248
|
+
let wake = () => {};
|
|
249
|
+
|
|
250
|
+
const poll = async () => {
|
|
251
|
+
const [status, recentResult] = await Promise.allSettled([
|
|
252
|
+
fetchUsage(credentials.baseUrl, credentials.key, fetchImpl),
|
|
253
|
+
fetchRecent(credentials.baseUrl, credentials.key, getLimit(), fetchImpl),
|
|
254
|
+
]);
|
|
255
|
+
const failed = [status, recentResult].filter((result) => result.status === "rejected");
|
|
256
|
+
state = {
|
|
257
|
+
...state,
|
|
258
|
+
report: status.status === "fulfilled" ? status.value : state.report,
|
|
259
|
+
recent: recentResult.status === "fulfilled" ? (recentResult.value.requests ?? []) : state.recent,
|
|
260
|
+
refreshedAt: status.status === "fulfilled" ? Date.now() : state.refreshedAt,
|
|
261
|
+
error: failed.length ? failed[0].reason.message : null,
|
|
262
|
+
};
|
|
263
|
+
onState(state);
|
|
264
|
+
if (!failed.length) {
|
|
265
|
+
failures = 0;
|
|
266
|
+
return intervalMs;
|
|
267
|
+
}
|
|
268
|
+
failures++;
|
|
269
|
+
const retryAfterMs = Math.max(0, ...failed.map((result) => (result.reason.retryAfter ?? 0) * 1000));
|
|
270
|
+
return Math.max(retryAfterMs, Math.min(intervalMs * 2 ** failures, MAX_BACKOFF_MS));
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const wait = (ms) =>
|
|
274
|
+
new Promise((resolve) => {
|
|
275
|
+
const timer = setTimeout(resolve, ms);
|
|
276
|
+
wake = () => {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
resolve();
|
|
279
|
+
};
|
|
280
|
+
signal.addEventListener("abort", wake, { once: true });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const done = (async () => {
|
|
284
|
+
while (!signal.aborted) {
|
|
285
|
+
const delay = await poll();
|
|
286
|
+
if (!signal.aborted) await wait(delay);
|
|
287
|
+
}
|
|
288
|
+
})();
|
|
289
|
+
return { done, refreshNow: () => wake() };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Run the dashboard: a live full-screen view on a TTY, or one plain frame otherwise.
|
|
294
|
+
* @param {{ baseUrl: string, key: string }} credentials Pool and key.
|
|
295
|
+
* @param {{ intervalSec: number, once: boolean }} options Parsed `dashboard` options.
|
|
296
|
+
* @param {{ stdout?: NodeJS.WriteStream, stdin?: NodeJS.ReadStream, env?: Record<string, string | undefined>, fetchImpl?: typeof fetch }} [io] Streams and environment (injected in tests).
|
|
297
|
+
* @returns {Promise<number>} Exit code: 0 on quit, 1 on a failed single frame, 2 when a single frame shows a block.
|
|
298
|
+
*/
|
|
299
|
+
export async function runDashboard(credentials, { intervalSec, once }, { stdout = process.stdout, stdin = process.stdin, env = process.env, fetchImpl = fetch } = {}) {
|
|
300
|
+
const live = !once && stdout.isTTY && stdin.isTTY && !env.NO_COLOR;
|
|
301
|
+
if (!live) return printOnce(credentials, intervalSec, stdout, fetchImpl);
|
|
302
|
+
|
|
303
|
+
const controller = new AbortController();
|
|
304
|
+
const size = () => ({ columns: stdout.columns || 80, rows: stdout.rows || 24 });
|
|
305
|
+
let state = null;
|
|
306
|
+
const draw = () => {
|
|
307
|
+
if (!state) return;
|
|
308
|
+
const { columns, rows } = size();
|
|
309
|
+
const frame = renderFrame(state, { columns, rows, color: true, now: Date.now() });
|
|
310
|
+
stdout.write(`\x1b[H${frame.split("\n").map((line) => `${line}\x1b[K`).join("\n")}\x1b[J`);
|
|
311
|
+
};
|
|
312
|
+
const restore = () => {
|
|
313
|
+
if (stdin.isRaw) stdin.setRawMode(false);
|
|
314
|
+
stdout.write(LEAVE_SCREEN);
|
|
315
|
+
};
|
|
316
|
+
const fail = (error) => {
|
|
317
|
+
restore();
|
|
318
|
+
console.error(`Error: ${error?.message ?? error}`);
|
|
319
|
+
process.exit(1);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
stdout.write(ENTER_SCREEN);
|
|
323
|
+
stdin.setRawMode(true);
|
|
324
|
+
stdin.resume();
|
|
325
|
+
process.once("exit", restore);
|
|
326
|
+
process.once("SIGTERM", () => controller.abort());
|
|
327
|
+
process.once("SIGINT", () => controller.abort());
|
|
328
|
+
process.once("uncaughtException", fail);
|
|
329
|
+
process.once("unhandledRejection", fail);
|
|
330
|
+
stdout.on("resize", draw);
|
|
331
|
+
|
|
332
|
+
const polling = startPolling(credentials, {
|
|
333
|
+
intervalMs: intervalSec * 1000,
|
|
334
|
+
getLimit: () => Math.min(Math.max(size().rows, 10), 100),
|
|
335
|
+
onState: (next) => {
|
|
336
|
+
state = next;
|
|
337
|
+
draw();
|
|
338
|
+
},
|
|
339
|
+
signal: controller.signal,
|
|
340
|
+
fetchImpl,
|
|
341
|
+
});
|
|
342
|
+
stdin.on("data", (chunk) => {
|
|
343
|
+
const keys = String(chunk);
|
|
344
|
+
if (keys === "q" || keys === "Q" || keys === "\x03") controller.abort();
|
|
345
|
+
else if (keys === "r" || keys === "R") polling.refreshNow();
|
|
346
|
+
});
|
|
347
|
+
const redraw = setInterval(draw, 1000);
|
|
348
|
+
|
|
349
|
+
await polling.done;
|
|
350
|
+
clearInterval(redraw);
|
|
351
|
+
stdout.off("resize", draw);
|
|
352
|
+
stdin.pause();
|
|
353
|
+
restore();
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Print a single plain (uncoloured) frame and finish.
|
|
359
|
+
* @param {{ baseUrl: string, key: string }} credentials Pool and key.
|
|
360
|
+
* @param {number} intervalSec Interval shown in the header.
|
|
361
|
+
* @param {NodeJS.WriteStream} stdout Output stream.
|
|
362
|
+
* @param {typeof fetch} fetchImpl Fetch implementation.
|
|
363
|
+
* @returns {Promise<number>} 1 on failure, 2 when blocked, else 0.
|
|
364
|
+
*/
|
|
365
|
+
async function printOnce(credentials, intervalSec, stdout, fetchImpl) {
|
|
366
|
+
const rows = stdout.rows || 30;
|
|
367
|
+
try {
|
|
368
|
+
const [report, recent] = await Promise.all([
|
|
369
|
+
fetchUsage(credentials.baseUrl, credentials.key, fetchImpl),
|
|
370
|
+
fetchRecent(credentials.baseUrl, credentials.key, Math.min(Math.max(rows, 10), 100), fetchImpl),
|
|
371
|
+
]);
|
|
372
|
+
const state = { report, recent: recent.requests ?? [], refreshedAt: Date.now(), error: null, intervalSec, poolHost: new URL(credentials.baseUrl).host };
|
|
373
|
+
stdout.write(`${renderFrame(state, { columns: stdout.columns || 80, rows, color: false, now: Date.now(), fill: false })}\n`);
|
|
374
|
+
return report.blocked ? 2 : 0;
|
|
375
|
+
} catch (error) {
|
|
376
|
+
console.error(`Error: ${error.message}`);
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
}
|
package/lib/usage.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function resolveCredentials(
|
|
2
|
+
settings: Record<string, any>,
|
|
3
|
+
env?: Record<string, string | undefined>,
|
|
4
|
+
): { baseUrl: string; key: string } | null;
|
|
5
|
+
export function fetchUsage(baseUrl: string, key: string, fetchImpl?: typeof fetch): Promise<Record<string, any>>;
|
|
6
|
+
export function fetchRecent(baseUrl: string, key: string, limit: number, fetchImpl?: typeof fetch): Promise<Record<string, any>>;
|
|
7
|
+
export function formatUsage(report: Record<string, any>, locale?: string, timeZone?: string): string;
|
package/lib/usage.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const STATUS_PATH = "/member/api/status";
|
|
4
|
+
const RECENT_PATH = "/member/api/recent";
|
|
5
|
+
const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pick the pool URL and key the way Claude Code does: shell variables win over settings.json.
|
|
10
|
+
* @param {Record<string, any>} settings Parsed settings.json ({} when missing).
|
|
11
|
+
* @param {Record<string, string | undefined>} [env] Environment to read ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN from.
|
|
12
|
+
* @returns {{ baseUrl: string, key: string } | null} Credentials, or null when either is missing.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveCredentials(settings, env = process.env) {
|
|
15
|
+
const fromFile = settings?.env ?? {};
|
|
16
|
+
const baseUrl = env.ANTHROPIC_BASE_URL || fromFile.ANTHROPIC_BASE_URL;
|
|
17
|
+
const key = env.ANTHROPIC_AUTH_TOKEN || fromFile.ANTHROPIC_AUTH_TOKEN;
|
|
18
|
+
return baseUrl && key ? { baseUrl: String(baseUrl).replace(/\/+$/, ""), key: String(key) } : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* GET a JSON endpoint on the pool with the member's key, mapping failures to readable errors.
|
|
23
|
+
* @param {string} baseUrl Pool origin.
|
|
24
|
+
* @param {string} path Absolute path (with query) on the pool.
|
|
25
|
+
* @param {string} key Gateway key.
|
|
26
|
+
* @param {typeof fetch} fetchImpl Fetch implementation.
|
|
27
|
+
* @returns {Promise<Record<string, any>>} The parsed body.
|
|
28
|
+
* @throws {Error} On network errors, 401, 429 (with `retryAfter` seconds when the pool sent it) and other failures.
|
|
29
|
+
*/
|
|
30
|
+
async function getJson(baseUrl, path, key, fetchImpl) {
|
|
31
|
+
let res;
|
|
32
|
+
try {
|
|
33
|
+
res = await fetchImpl(`${baseUrl}${path}`, {
|
|
34
|
+
headers: { authorization: `Bearer ${key}`, "x-tokenhub-cli": CLI_VERSION },
|
|
35
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw new Error(`Could not reach ${baseUrl} (${error.message})`);
|
|
39
|
+
}
|
|
40
|
+
if (res.status === 401) throw new Error("The pool rejected this key. It may be revoked; run `tokenhub connect` again.");
|
|
41
|
+
if (res.status === 429) {
|
|
42
|
+
const retryAfter = res.headers.get("retry-after");
|
|
43
|
+
throw Object.assign(new Error(`Too many requests. Retry in ${retryAfter ?? "a few"}s.`), {
|
|
44
|
+
retryAfter: Number(retryAfter) > 0 ? Number(retryAfter) : undefined,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (!res.ok) throw new Error(`Pool answered HTTP ${res.status}`);
|
|
48
|
+
return res.json();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch the member's status from the pool.
|
|
53
|
+
* @param {string} baseUrl Pool origin.
|
|
54
|
+
* @param {string} key Gateway key.
|
|
55
|
+
* @param {typeof fetch} [fetchImpl] Fetch implementation (injected in tests).
|
|
56
|
+
* @returns {Promise<Record<string, any>>} The `GET /member/api/status` body.
|
|
57
|
+
* @throws {Error} With a user-readable message on network errors, 401/429 and other failures.
|
|
58
|
+
*/
|
|
59
|
+
export function fetchUsage(baseUrl, key, fetchImpl = fetch) {
|
|
60
|
+
return getJson(baseUrl, STATUS_PATH, key, fetchImpl);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fetch the member's most recent requests, newest first.
|
|
65
|
+
* @param {string} baseUrl Pool origin.
|
|
66
|
+
* @param {string} key Gateway key.
|
|
67
|
+
* @param {number} limit How many requests to ask for.
|
|
68
|
+
* @param {typeof fetch} [fetchImpl] Fetch implementation (injected in tests).
|
|
69
|
+
* @returns {Promise<Record<string, any>>} The `GET /member/api/recent` body (`{ requests: [...] }`).
|
|
70
|
+
* @throws {Error} Same mapping as `fetchUsage`.
|
|
71
|
+
*/
|
|
72
|
+
export function fetchRecent(baseUrl, key, limit, fetchImpl = fetch) {
|
|
73
|
+
return getJson(baseUrl, `${RECENT_PATH}?limit=${encodeURIComponent(limit)}`, key, fetchImpl);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render a status report as a few readable lines.
|
|
78
|
+
* @param {Record<string, any>} report Body returned by `fetchUsage`.
|
|
79
|
+
* @param {string} [locale] Locale for reset times (system default when omitted).
|
|
80
|
+
* @param {string} [timeZone] IANA zone for reset times (system default when omitted).
|
|
81
|
+
* @returns {string} Multi-line text without a trailing newline.
|
|
82
|
+
*/
|
|
83
|
+
export function formatUsage(report, locale, timeZone) {
|
|
84
|
+
const when = (iso) => new Date(iso).toLocaleString(locale, { timeZone, dateStyle: "medium", timeStyle: "short" });
|
|
85
|
+
const money = (amount) => `$${Number(amount).toFixed(2)}`;
|
|
86
|
+
const window = (label, w) => {
|
|
87
|
+
const spend = w.limit_usd === null ? `${money(w.spent_usd)} (no limit)` : `${money(w.spent_usd)} / ${money(w.limit_usd)}`;
|
|
88
|
+
return `${label.padEnd(8)}${spend.padEnd(26)}resets ${when(w.resets_at)}`;
|
|
89
|
+
};
|
|
90
|
+
const lines = [
|
|
91
|
+
`${report.member} @ TokenHub`,
|
|
92
|
+
window("Today", report.today),
|
|
93
|
+
window("Week", report.week),
|
|
94
|
+
`${"Credit".padEnd(8)}${money(report.credit_usd)}`,
|
|
95
|
+
`${"Pool".padEnd(8)}${report.pool?.state ?? "unknown"}`,
|
|
96
|
+
];
|
|
97
|
+
if (report.blocked) lines.push(blockedLine(report, when));
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Explain why the gateway would refuse this member's requests.
|
|
103
|
+
* @param {Record<string, any>} report Status report with `blocked` set.
|
|
104
|
+
* @param {(iso: string) => string} when Formats an ISO time for display.
|
|
105
|
+
* @returns {string} One line.
|
|
106
|
+
*/
|
|
107
|
+
function blockedLine(report, when) {
|
|
108
|
+
if (report.blocked_reason === "credit") return "Blocked: out of credit. Top up on the member site.";
|
|
109
|
+
const period = report.blocked_reason === "weekly_budget" ? "weekly" : "daily";
|
|
110
|
+
return `Blocked: ${period} budget reached${report.blocked_until ? `, lifts ${when(report.blocked_until)}` : ""}.`;
|
|
111
|
+
}
|