@emesoft/tokenhub 0.1.0 → 0.2.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 +7 -2
- package/bin/tokenhub.js +18 -1
- package/lib/usage.d.ts +6 -0
- package/lib/usage.js +76 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
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
|
|
7
8
|
npx @emesoft/tokenhub disconnect
|
|
8
9
|
```
|
|
9
10
|
|
|
10
11
|
`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
12
|
|
|
13
|
+
`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).
|
|
14
|
+
|
|
12
15
|
`disconnect` removes only those two entries.
|
|
13
16
|
|
|
14
17
|
If `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` is set in your shell, it overrides `settings.json`.
|
|
18
|
+
|
|
19
|
+
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,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { runConnect } from "../lib/pair.js";
|
|
4
|
-
import { disconnectSettings, settingsPath } from "../lib/settings.js";
|
|
4
|
+
import { disconnectSettings, readSettings, settingsPath } from "../lib/settings.js";
|
|
5
|
+
import { fetchUsage, formatUsage, resolveCredentials } from "../lib/usage.js";
|
|
5
6
|
|
|
6
7
|
const HELP = `tokenhub - pair Claude Code with a TokenHub pool
|
|
7
8
|
|
|
8
9
|
Usage:
|
|
9
10
|
tokenhub connect <poolUrl> Pair this machine and write ~/.claude/settings.json
|
|
11
|
+
tokenhub usage [--json] Show today's and this week's spend, credit and pool health
|
|
10
12
|
tokenhub disconnect Remove the pool entries from settings.json
|
|
11
13
|
tokenhub --help | --version
|
|
12
14
|
`;
|
|
@@ -37,6 +39,21 @@ async function main(argv) {
|
|
|
37
39
|
process.once("SIGINT", () => controller.abort());
|
|
38
40
|
return runConnect({ poolUrl: arg, settingsFile: file, signal: controller.signal });
|
|
39
41
|
}
|
|
42
|
+
if (command === "usage") {
|
|
43
|
+
try {
|
|
44
|
+
const credentials = resolveCredentials(readSettings(file).settings);
|
|
45
|
+
if (!credentials) {
|
|
46
|
+
console.error("Not connected. Run `tokenhub connect <poolUrl>` first.");
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const report = await fetchUsage(credentials.baseUrl, credentials.key);
|
|
50
|
+
console.log(arg === "--json" ? JSON.stringify(report, null, 2) : formatUsage(report));
|
|
51
|
+
return report.blocked ? 2 : 0;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(`Error: ${error.message}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
40
57
|
if (command === "disconnect") {
|
|
41
58
|
try {
|
|
42
59
|
console.log(disconnectSettings(file) ? `Disconnected. Updated ${file}` : `Nothing to remove in ${file}`);
|
package/lib/usage.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
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 formatUsage(report: Record<string, any>, locale?: string, timeZone?: string): string;
|
package/lib/usage.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const STATUS_PATH = "/member/api/status";
|
|
2
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pick the pool URL and key the way Claude Code does: shell variables win over settings.json.
|
|
6
|
+
* @param {Record<string, any>} settings Parsed settings.json ({} when missing).
|
|
7
|
+
* @param {Record<string, string | undefined>} [env] Environment to read ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN from.
|
|
8
|
+
* @returns {{ baseUrl: string, key: string } | null} Credentials, or null when either is missing.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveCredentials(settings, env = process.env) {
|
|
11
|
+
const fromFile = settings?.env ?? {};
|
|
12
|
+
const baseUrl = env.ANTHROPIC_BASE_URL || fromFile.ANTHROPIC_BASE_URL;
|
|
13
|
+
const key = env.ANTHROPIC_AUTH_TOKEN || fromFile.ANTHROPIC_AUTH_TOKEN;
|
|
14
|
+
return baseUrl && key ? { baseUrl: String(baseUrl).replace(/\/+$/, ""), key: String(key) } : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch the member's status from the pool.
|
|
19
|
+
* @param {string} baseUrl Pool origin.
|
|
20
|
+
* @param {string} key Gateway key.
|
|
21
|
+
* @param {typeof fetch} [fetchImpl] Fetch implementation (injected in tests).
|
|
22
|
+
* @returns {Promise<Record<string, any>>} The `GET /member/api/status` body.
|
|
23
|
+
* @throws {Error} With a user-readable message on network errors, 401/429 and other failures.
|
|
24
|
+
*/
|
|
25
|
+
export async function fetchUsage(baseUrl, key, fetchImpl = fetch) {
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await fetchImpl(`${baseUrl}${STATUS_PATH}`, {
|
|
29
|
+
headers: { authorization: `Bearer ${key}` },
|
|
30
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
31
|
+
});
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`Could not reach ${baseUrl} (${error.message})`);
|
|
34
|
+
}
|
|
35
|
+
if (res.status === 401) throw new Error("The pool rejected this key. It may be revoked; run `tokenhub connect` again.");
|
|
36
|
+
if (res.status === 429) throw new Error(`Too many requests. Retry in ${res.headers.get("retry-after") ?? "a few"}s.`);
|
|
37
|
+
if (!res.ok) throw new Error(`Pool answered HTTP ${res.status}`);
|
|
38
|
+
return res.json();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render a status report as a few readable lines.
|
|
43
|
+
* @param {Record<string, any>} report Body returned by `fetchUsage`.
|
|
44
|
+
* @param {string} [locale] Locale for reset times (system default when omitted).
|
|
45
|
+
* @param {string} [timeZone] IANA zone for reset times (system default when omitted).
|
|
46
|
+
* @returns {string} Multi-line text without a trailing newline.
|
|
47
|
+
*/
|
|
48
|
+
export function formatUsage(report, locale, timeZone) {
|
|
49
|
+
const when = (iso) => new Date(iso).toLocaleString(locale, { timeZone, dateStyle: "medium", timeStyle: "short" });
|
|
50
|
+
const money = (amount) => `$${Number(amount).toFixed(2)}`;
|
|
51
|
+
const window = (label, w) => {
|
|
52
|
+
const spend = w.limit_usd === null ? `${money(w.spent_usd)} (no limit)` : `${money(w.spent_usd)} / ${money(w.limit_usd)}`;
|
|
53
|
+
return `${label.padEnd(8)}${spend.padEnd(26)}resets ${when(w.resets_at)}`;
|
|
54
|
+
};
|
|
55
|
+
const lines = [
|
|
56
|
+
`${report.member} @ TokenHub`,
|
|
57
|
+
window("Today", report.today),
|
|
58
|
+
window("Week", report.week),
|
|
59
|
+
`${"Credit".padEnd(8)}${money(report.credit_usd)}`,
|
|
60
|
+
`${"Pool".padEnd(8)}${report.pool?.state ?? "unknown"}`,
|
|
61
|
+
];
|
|
62
|
+
if (report.blocked) lines.push(blockedLine(report, when));
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Explain why the gateway would refuse this member's requests.
|
|
68
|
+
* @param {Record<string, any>} report Status report with `blocked` set.
|
|
69
|
+
* @param {(iso: string) => string} when Formats an ISO time for display.
|
|
70
|
+
* @returns {string} One line.
|
|
71
|
+
*/
|
|
72
|
+
function blockedLine(report, when) {
|
|
73
|
+
if (report.blocked_reason === "credit") return "Blocked: out of credit. Top up on the member site.";
|
|
74
|
+
const period = report.blocked_reason === "weekly_budget" ? "weekly" : "daily";
|
|
75
|
+
return `Blocked: ${period} budget reached${report.blocked_until ? `, lifts ${when(report.blocked_until)}` : ""}.`;
|
|
76
|
+
}
|