@zhangferry-dev/tokendash 1.6.2 → 1.7.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 -6
- package/dist/daemon.cjs +159 -54
- package/dist/daemon.cjs.map +2 -2
- package/dist/electron-server.cjs +159 -54
- package/dist/electron-server.cjs.map +2 -2
- package/dist/server/index.js +1 -0
- package/dist/server/quota/adapter.d.ts +4 -2
- package/dist/server/quota/adapters/claude.d.ts +2 -0
- package/dist/server/quota/adapters/claude.js +49 -21
- package/dist/server/quota/adapters/codex.d.ts +14 -0
- package/dist/server/quota/adapters/codex.js +65 -27
- package/dist/server/quota/adapters/glm.js +9 -3
- package/dist/server/quota/adapters/kimi.js +4 -2
- package/dist/server/quota/adapters/minimax.js +8 -3
- package/dist/server/quota/index.d.ts +1 -1
- package/dist/server/quota/quotaService.d.ts +7 -1
- package/dist/server/quota/quotaService.js +32 -10
- package/dist/server/quota/types.d.ts +11 -0
- package/dist/server/routes/api.js +19 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="resources/icon.png" width="132" alt="TokenDash app icon">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
# TokenDash
|
|
8
|
+
|
|
4
9
|
**Your local command center for AI coding usage.**
|
|
5
10
|
|
|
6
11
|
Track tokens, costs, models, projects, and coding-plan limits from the macOS menu bar, with a detailed local dashboard when you need to dig deeper.
|
|
@@ -13,10 +18,6 @@
|
|
|
13
18
|
[Download for macOS](https://github.com/zhangferry/tokendash/releases/latest) · [Run with npx](#run-the-web-dashboard) · [Features](#what-you-get) · [Development](#development)
|
|
14
19
|
</div>
|
|
15
20
|
|
|
16
|
-
<p align="center">
|
|
17
|
-
<img src="resources/icon.png" width="132" alt="TokenDash app icon">
|
|
18
|
-
</p>
|
|
19
|
-
|
|
20
21
|

|
|
21
22
|
|
|
22
23
|
## Why TokenDash?
|
package/dist/daemon.cjs
CHANGED
|
@@ -2247,6 +2247,32 @@ var QuotaService = class {
|
|
|
2247
2247
|
}
|
|
2248
2248
|
return this.fetchAll();
|
|
2249
2249
|
}
|
|
2250
|
+
/**
|
|
2251
|
+
* Validate a credential without caching it or writing it to disk. This keeps
|
|
2252
|
+
* the settings form transactional: only credentials accepted upstream are
|
|
2253
|
+
* persisted by the native app.
|
|
2254
|
+
*/
|
|
2255
|
+
async validateCredential(provider, credential) {
|
|
2256
|
+
const adapter = this.registry.get(provider);
|
|
2257
|
+
if (!adapter) {
|
|
2258
|
+
return {
|
|
2259
|
+
provider,
|
|
2260
|
+
valid: false,
|
|
2261
|
+
status: { state: "not_configured", message: "Unsupported provider" }
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
try {
|
|
2265
|
+
const snapshot = await withTimeout(
|
|
2266
|
+
adapter.fetch({ credential }),
|
|
2267
|
+
this.fetchTimeoutMs,
|
|
2268
|
+
provider
|
|
2269
|
+
);
|
|
2270
|
+
const validated = validateQuotaSnapshot(snapshot);
|
|
2271
|
+
return { provider, valid: validated.status.state === "ok", status: validated.status };
|
|
2272
|
+
} catch (err) {
|
|
2273
|
+
return { provider, valid: false, status: statusForError(err, this.fetchTimeoutMs) };
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2250
2276
|
async fetchWithTimeout(adapter) {
|
|
2251
2277
|
try {
|
|
2252
2278
|
const snapshot = await withTimeout(adapter.fetch(), this.fetchTimeoutMs, adapter.provider);
|
|
@@ -2258,14 +2284,7 @@ var QuotaService = class {
|
|
|
2258
2284
|
}
|
|
2259
2285
|
}
|
|
2260
2286
|
handleFailure(adapter, err) {
|
|
2261
|
-
|
|
2262
|
-
if (err instanceof QuotaError) {
|
|
2263
|
-
status = err.status;
|
|
2264
|
-
} else if (err instanceof TimeoutError) {
|
|
2265
|
-
status = { state: "timed_out", message: `upstream did not respond within ${this.fetchTimeoutMs}ms` };
|
|
2266
|
-
} else {
|
|
2267
|
-
status = { state: "error", message: redact(err), category: "unexpected" };
|
|
2268
|
-
}
|
|
2287
|
+
const status = statusForError(err, this.fetchTimeoutMs);
|
|
2269
2288
|
const stale = this.cache.getStale(adapter.provider);
|
|
2270
2289
|
if (stale) {
|
|
2271
2290
|
return { ...stale, freshness: "stale", status };
|
|
@@ -2280,6 +2299,13 @@ var QuotaService = class {
|
|
|
2280
2299
|
};
|
|
2281
2300
|
}
|
|
2282
2301
|
};
|
|
2302
|
+
function statusForError(err, timeoutMs) {
|
|
2303
|
+
if (err instanceof QuotaError) return err.status;
|
|
2304
|
+
if (err instanceof TimeoutError) {
|
|
2305
|
+
return { state: "timed_out", message: `upstream did not respond within ${timeoutMs}ms` };
|
|
2306
|
+
}
|
|
2307
|
+
return { state: "error", message: redact(err), category: "unexpected" };
|
|
2308
|
+
}
|
|
2283
2309
|
var TimeoutError = class extends Error {
|
|
2284
2310
|
constructor(provider) {
|
|
2285
2311
|
super(`quota fetch timed out: ${provider}`);
|
|
@@ -2404,8 +2430,7 @@ var codexAdapter = {
|
|
|
2404
2430
|
provider: "codex",
|
|
2405
2431
|
displayName: "OpenAI Codex",
|
|
2406
2432
|
async isConfigured() {
|
|
2407
|
-
|
|
2408
|
-
return await codexBinaryAvailable();
|
|
2433
|
+
return (0, import_node_fs8.existsSync)((0, import_node_path8.join)(codexHome(), "auth.json")) && resolveCodexBinary() !== null;
|
|
2409
2434
|
},
|
|
2410
2435
|
async fetch() {
|
|
2411
2436
|
const result = await queryRateLimits();
|
|
@@ -2448,10 +2473,16 @@ function capitalize(s) {
|
|
|
2448
2473
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
2449
2474
|
}
|
|
2450
2475
|
async function queryRateLimits() {
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2476
|
+
const codexBinary = resolveCodexBinary();
|
|
2477
|
+
if (!codexBinary) {
|
|
2478
|
+
throw new QuotaError({ state: "not_configured", message: "official Codex CLI not found" });
|
|
2479
|
+
}
|
|
2480
|
+
const binaryDir = (0, import_node_path8.dirname)(codexBinary);
|
|
2481
|
+
const childPath = [binaryDir, process.env.PATH].filter(Boolean).join(":");
|
|
2482
|
+
const proc = (0, import_node_child_process2.spawn)(codexBinary, ["app-server"], {
|
|
2483
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2484
|
+
env: { ...process.env, PATH: childPath }
|
|
2485
|
+
});
|
|
2455
2486
|
const client = new JsonRpcClient(proc);
|
|
2456
2487
|
try {
|
|
2457
2488
|
await client.request("initialize", {
|
|
@@ -2540,24 +2571,44 @@ var JsonRpcClient = class {
|
|
|
2540
2571
|
this.pending.clear();
|
|
2541
2572
|
}
|
|
2542
2573
|
};
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
})
|
|
2560
|
-
|
|
2574
|
+
function resolveCodexBinary(options = {}) {
|
|
2575
|
+
const home = options.home ?? (0, import_node_os8.homedir)();
|
|
2576
|
+
const path = options.path ?? process.env.PATH ?? "";
|
|
2577
|
+
const explicitBinary = options.explicitBinary ?? process.env.CODEX_BIN;
|
|
2578
|
+
const isExecutable = options.isExecutable ?? defaultIsExecutable;
|
|
2579
|
+
const nvmRoot = (0, import_node_path8.join)(home, ".nvm", "versions", "node");
|
|
2580
|
+
const nvmVersions = options.nvmVersions ?? readDirectoryNames(nvmRoot);
|
|
2581
|
+
const candidates = [
|
|
2582
|
+
explicitBinary,
|
|
2583
|
+
"/Applications/Codex.app/Contents/Resources/codex",
|
|
2584
|
+
(0, import_node_path8.join)(home, "Applications", "Codex.app", "Contents", "Resources", "codex"),
|
|
2585
|
+
"/opt/homebrew/bin/codex",
|
|
2586
|
+
"/usr/local/bin/codex",
|
|
2587
|
+
(0, import_node_path8.join)(home, ".local", "bin", "codex"),
|
|
2588
|
+
(0, import_node_path8.join)(home, ".volta", "bin", "codex"),
|
|
2589
|
+
(0, import_node_path8.join)(home, ".bun", "bin", "codex"),
|
|
2590
|
+
...nvmVersions.sort((a, b) => b.localeCompare(a, void 0, { numeric: true })).map((version) => (0, import_node_path8.join)(nvmRoot, version, "bin", "codex")),
|
|
2591
|
+
...path.split(":").filter(Boolean).map((directory) => (0, import_node_path8.join)(directory, "codex"))
|
|
2592
|
+
];
|
|
2593
|
+
for (const candidate of candidates) {
|
|
2594
|
+
if (candidate && isExecutable(candidate)) return candidate;
|
|
2595
|
+
}
|
|
2596
|
+
return null;
|
|
2597
|
+
}
|
|
2598
|
+
function defaultIsExecutable(candidate) {
|
|
2599
|
+
try {
|
|
2600
|
+
(0, import_node_fs8.accessSync)(candidate, import_node_fs8.constants.X_OK);
|
|
2601
|
+
return true;
|
|
2602
|
+
} catch {
|
|
2603
|
+
return false;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
function readDirectoryNames(directory) {
|
|
2607
|
+
try {
|
|
2608
|
+
return (0, import_node_fs8.readdirSync)(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
2609
|
+
} catch {
|
|
2610
|
+
return [];
|
|
2611
|
+
}
|
|
2561
2612
|
}
|
|
2562
2613
|
|
|
2563
2614
|
// src/server/quota/adapters/claude.ts
|
|
@@ -2565,6 +2616,7 @@ var import_node_fs9 = require("node:fs");
|
|
|
2565
2616
|
var import_node_path9 = require("node:path");
|
|
2566
2617
|
var import_node_os9 = require("node:os");
|
|
2567
2618
|
var import_node_child_process3 = require("node:child_process");
|
|
2619
|
+
var import_node_crypto = require("node:crypto");
|
|
2568
2620
|
var claudeAdapter = {
|
|
2569
2621
|
provider: "claude",
|
|
2570
2622
|
displayName: "Claude Code",
|
|
@@ -2633,7 +2685,7 @@ function readClaudeToken() {
|
|
|
2633
2685
|
if ((0, import_node_fs9.existsSync)(credPath)) {
|
|
2634
2686
|
try {
|
|
2635
2687
|
const parsed = JSON.parse((0, import_node_fs9.readFileSync)(credPath, "utf8"));
|
|
2636
|
-
return parsed
|
|
2688
|
+
return extractClaudeAccessToken(parsed);
|
|
2637
2689
|
} catch {
|
|
2638
2690
|
return null;
|
|
2639
2691
|
}
|
|
@@ -2641,33 +2693,55 @@ function readClaudeToken() {
|
|
|
2641
2693
|
return null;
|
|
2642
2694
|
}
|
|
2643
2695
|
function readFromKeychain() {
|
|
2644
|
-
const candidates =
|
|
2696
|
+
const candidates = claudeKeychainServiceNames(process.env.CLAUDE_CONFIG_DIR);
|
|
2645
2697
|
try {
|
|
2646
2698
|
const list = (0, import_node_child_process3.execFileSync)("security", ["dump-keychain"], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
2647
|
-
for (const m of list.matchAll(/"
|
|
2699
|
+
for (const m of list.matchAll(/"svce"<blob>="([^"]*Claude Code-credentials[^"]*)"/g)) {
|
|
2648
2700
|
if (m[1] && !candidates.includes(m[1])) candidates.push(m[1]);
|
|
2649
2701
|
}
|
|
2650
2702
|
} catch {
|
|
2651
2703
|
}
|
|
2704
|
+
const accounts = [safeUsername(), void 0];
|
|
2652
2705
|
for (const name of candidates) {
|
|
2653
|
-
|
|
2654
|
-
const raw = (0, import_node_child_process3.execFileSync)("security", ["find-generic-password", "-s", name, "-w"], {
|
|
2655
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
2656
|
-
encoding: "utf8"
|
|
2657
|
-
}).trim();
|
|
2658
|
-
if (!raw) continue;
|
|
2706
|
+
for (const account of accounts) {
|
|
2659
2707
|
try {
|
|
2660
|
-
const
|
|
2661
|
-
|
|
2708
|
+
const args = ["find-generic-password", "-s", name];
|
|
2709
|
+
if (account) args.push("-a", account);
|
|
2710
|
+
args.push("-w");
|
|
2711
|
+
const raw = (0, import_node_child_process3.execFileSync)("/usr/bin/security", args, {
|
|
2712
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2713
|
+
encoding: "utf8",
|
|
2714
|
+
timeout: 2e3
|
|
2715
|
+
}).trim();
|
|
2716
|
+
if (!raw) continue;
|
|
2717
|
+
const token = extractClaudeAccessToken(JSON.parse(raw));
|
|
2718
|
+
if (token) return token;
|
|
2662
2719
|
} catch {
|
|
2663
|
-
|
|
2720
|
+
continue;
|
|
2664
2721
|
}
|
|
2665
|
-
} catch {
|
|
2666
|
-
continue;
|
|
2667
2722
|
}
|
|
2668
2723
|
}
|
|
2669
2724
|
return null;
|
|
2670
2725
|
}
|
|
2726
|
+
function claudeKeychainServiceNames(configDir) {
|
|
2727
|
+
if (!configDir) return ["Claude Code-credentials"];
|
|
2728
|
+
const hash = (0, import_node_crypto.createHash)("sha256").update(configDir).digest("hex").slice(0, 8);
|
|
2729
|
+
return [`Claude Code-credentials-${hash}`, "Claude Code-credentials"];
|
|
2730
|
+
}
|
|
2731
|
+
function extractClaudeAccessToken(value) {
|
|
2732
|
+
if (!value || typeof value !== "object") return null;
|
|
2733
|
+
const root = value;
|
|
2734
|
+
const nested = root.claudeAiOauth;
|
|
2735
|
+
const credentials = nested && typeof nested === "object" ? nested : root;
|
|
2736
|
+
return typeof credentials.accessToken === "string" && credentials.accessToken ? credentials.accessToken : null;
|
|
2737
|
+
}
|
|
2738
|
+
function safeUsername() {
|
|
2739
|
+
try {
|
|
2740
|
+
return (0, import_node_os9.userInfo)().username?.trim() || void 0;
|
|
2741
|
+
} catch {
|
|
2742
|
+
return void 0;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2671
2745
|
|
|
2672
2746
|
// src/server/quota/adapters/glm.ts
|
|
2673
2747
|
var import_node_fs11 = require("node:fs");
|
|
@@ -2703,8 +2777,8 @@ var glmAdapter = {
|
|
|
2703
2777
|
async isConfigured() {
|
|
2704
2778
|
return !!resolveCredential();
|
|
2705
2779
|
},
|
|
2706
|
-
async fetch() {
|
|
2707
|
-
const cred = resolveCredential();
|
|
2780
|
+
async fetch(options) {
|
|
2781
|
+
const cred = resolveCredential(options?.credential);
|
|
2708
2782
|
if (!cred) {
|
|
2709
2783
|
throw new QuotaError({ state: "not_configured", message: "set ZAI_API_KEY or ZHIPU_API_KEY" });
|
|
2710
2784
|
}
|
|
@@ -2760,7 +2834,13 @@ function classifyFetchError2(err) {
|
|
|
2760
2834
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2761
2835
|
return new QuotaError({ state: "upstream_unavailable", message: msg.slice(0, 200) });
|
|
2762
2836
|
}
|
|
2763
|
-
function resolveCredential() {
|
|
2837
|
+
function resolveCredential(proposed) {
|
|
2838
|
+
if (proposed?.apiKey) {
|
|
2839
|
+
return {
|
|
2840
|
+
key: proposed.apiKey,
|
|
2841
|
+
base: proposed.baseUrl || "https://open.bigmodel.cn"
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2764
2844
|
const stored = readStoredCredential("glm");
|
|
2765
2845
|
if (stored) return { key: stored.apiKey, base: stored.baseUrl || "https://open.bigmodel.cn" };
|
|
2766
2846
|
const zai = envOrConfig("ZAI_API_KEY");
|
|
@@ -2815,8 +2895,8 @@ var minimaxAdapter = {
|
|
|
2815
2895
|
async isConfigured() {
|
|
2816
2896
|
return !!resolveCredential2();
|
|
2817
2897
|
},
|
|
2818
|
-
async fetch() {
|
|
2819
|
-
const cred = resolveCredential2();
|
|
2898
|
+
async fetch(options) {
|
|
2899
|
+
const cred = resolveCredential2(options?.credential);
|
|
2820
2900
|
if (!cred) {
|
|
2821
2901
|
throw new QuotaError({ state: "not_configured", message: "set MINIMAX_API_KEY (Subscription Key)" });
|
|
2822
2902
|
}
|
|
@@ -2875,7 +2955,12 @@ function classifyFetchError3(err) {
|
|
|
2875
2955
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2876
2956
|
return new QuotaError({ state: "upstream_unavailable", message: msg.slice(0, 200) });
|
|
2877
2957
|
}
|
|
2878
|
-
function resolveCredential2() {
|
|
2958
|
+
function resolveCredential2(proposed) {
|
|
2959
|
+
if (proposed?.apiKey) {
|
|
2960
|
+
const region2 = (process.env.MINIMAX_REGION || "").toLowerCase();
|
|
2961
|
+
const base2 = proposed.baseUrl || (region2 === "cn" ? "https://www.minimaxi.com" : "https://www.minimax.io");
|
|
2962
|
+
return { key: proposed.apiKey, base: base2 };
|
|
2963
|
+
}
|
|
2879
2964
|
const stored = readStoredCredential("minimax");
|
|
2880
2965
|
if (stored) {
|
|
2881
2966
|
const region2 = (process.env.MINIMAX_REGION || "").toLowerCase();
|
|
@@ -2903,9 +2988,9 @@ var kimiAdapter = {
|
|
|
2903
2988
|
const cred = readCredentials();
|
|
2904
2989
|
return !!cred && !!cred.access_token;
|
|
2905
2990
|
},
|
|
2906
|
-
async fetch() {
|
|
2991
|
+
async fetch(options) {
|
|
2907
2992
|
const credPath = credentialsPath();
|
|
2908
|
-
let cred = readCredentials();
|
|
2993
|
+
let cred = options?.credential?.apiKey ? { access_token: options.credential.apiKey, token_type: "Bearer" } : readCredentials();
|
|
2909
2994
|
if (!cred || !cred.access_token) {
|
|
2910
2995
|
throw new QuotaError({ state: "not_configured", message: "run `kimi` to log in first" });
|
|
2911
2996
|
}
|
|
@@ -3057,6 +3142,24 @@ async function getQuota(_req, res) {
|
|
|
3057
3142
|
res.status(500).json({ error: "Failed to fetch quota", hint: message });
|
|
3058
3143
|
}
|
|
3059
3144
|
}
|
|
3145
|
+
var editableQuotaProviders = /* @__PURE__ */ new Set(["glm", "kimi", "minimax"]);
|
|
3146
|
+
async function validateQuotaCredential(req, res) {
|
|
3147
|
+
const provider = req.body?.provider;
|
|
3148
|
+
const apiKey = typeof req.body?.apiKey === "string" ? req.body.apiKey.trim() : "";
|
|
3149
|
+
const baseUrl = typeof req.body?.baseUrl === "string" ? req.body.baseUrl.trim() : void 0;
|
|
3150
|
+
if (!editableQuotaProviders.has(provider) || !apiKey) {
|
|
3151
|
+
res.status(400).json({
|
|
3152
|
+
error: "Invalid credential request",
|
|
3153
|
+
hint: "A supported provider and non-empty token are required."
|
|
3154
|
+
});
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
const result = await quotaService.validateCredential(provider, {
|
|
3158
|
+
apiKey,
|
|
3159
|
+
baseUrl: baseUrl || void 0
|
|
3160
|
+
});
|
|
3161
|
+
res.status(result.valid ? 200 : 422).json(result);
|
|
3162
|
+
}
|
|
3060
3163
|
function getAgents(_req, res) {
|
|
3061
3164
|
try {
|
|
3062
3165
|
const agents = detectAvailableAgents();
|
|
@@ -3090,6 +3193,7 @@ function registerApiRoutes(router, appInfo) {
|
|
|
3090
3193
|
router.get("/blocks", getBlocks);
|
|
3091
3194
|
router.get("/analytics", getAnalytics);
|
|
3092
3195
|
router.get("/quota", getQuota);
|
|
3196
|
+
router.post("/quota/validate", validateQuotaCredential);
|
|
3093
3197
|
}
|
|
3094
3198
|
|
|
3095
3199
|
// src/server/index.ts
|
|
@@ -3133,6 +3237,7 @@ function resolveStaticAssetBaseDir(moduleUrl = __esbuild_import_meta_url, baseDi
|
|
|
3133
3237
|
function createApp(_port, baseDir) {
|
|
3134
3238
|
const app = (0, import_express.default)();
|
|
3135
3239
|
const router = import_express.default.Router();
|
|
3240
|
+
app.use(import_express.default.json({ limit: "16kb" }));
|
|
3136
3241
|
registerApiRoutes(router, {
|
|
3137
3242
|
packageName: PACKAGE_NAME,
|
|
3138
3243
|
version: getPackageVersion(),
|