@zhangferry-dev/tokendash 1.6.1 → 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 +148 -84
- package/dist/client/assets/index-Bw503sNp.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/daemon.cjs +3411 -0
- package/dist/daemon.cjs.map +7 -0
- package/dist/electron-server.cjs +1124 -28
- package/dist/electron-server.cjs.map +4 -4
- package/dist/server/ccusage.d.ts +7 -0
- package/dist/server/ccusage.js +69 -0
- package/dist/server/daemon.d.ts +12 -0
- package/dist/server/daemon.js +176 -0
- package/dist/server/index.js +23 -11
- package/dist/server/insightsCalculator.d.ts +15 -0
- package/dist/server/insightsCalculator.js +276 -0
- package/dist/server/quota/adapter.d.ts +49 -0
- package/dist/server/quota/adapter.js +41 -0
- package/dist/server/quota/adapters/claude.d.ts +4 -0
- package/dist/server/quota/adapters/claude.js +152 -0
- package/dist/server/quota/adapters/codex.d.ts +16 -0
- package/dist/server/quota/adapters/codex.js +226 -0
- package/dist/server/quota/adapters/glm.d.ts +2 -0
- package/dist/server/quota/adapters/glm.js +139 -0
- package/dist/server/quota/adapters/kimi.d.ts +2 -0
- package/dist/server/quota/adapters/kimi.js +186 -0
- package/dist/server/quota/adapters/minimax.d.ts +2 -0
- package/dist/server/quota/adapters/minimax.js +82 -0
- package/dist/server/quota/cache.d.ts +20 -0
- package/dist/server/quota/cache.js +44 -0
- package/dist/server/quota/credentialsFile.d.ts +13 -0
- package/dist/server/quota/credentialsFile.js +23 -0
- package/dist/server/quota/helpers.d.ts +39 -0
- package/dist/server/quota/helpers.js +93 -0
- package/dist/server/quota/index.d.ts +5 -0
- package/dist/server/quota/index.js +23 -0
- package/dist/server/quota/quotaService.d.ts +43 -0
- package/dist/server/quota/quotaService.js +163 -0
- package/dist/server/quota/schemas.d.ts +358 -0
- package/dist/server/quota/schemas.js +53 -0
- package/dist/server/quota/types.d.ts +76 -0
- package/dist/server/quota/types.js +10 -0
- package/dist/server/routes/api.js +34 -0
- package/dist/server/routes/insights.d.ts +2 -0
- package/dist/server/routes/insights.js +155 -0
- package/package.json +9 -11
- package/resources/entitlements.mac.plist +10 -0
- package/resources/icon-1024.png +0 -0
- package/resources/icon.icns +0 -0
- package/resources/icon.png +0 -0
- package/resources/product_menu.png +0 -0
- package/resources/readme-hero.png +0 -0
- package/dist/client/assets/index-_yA9tOzZ.css +0 -1
- package/electron/main.cjs +0 -516
- package/electron/npmSync.cjs +0 -62
- package/electron/preload.cjs +0 -36
- package/electron/serverReuse.cjs +0 -59
- package/electron/trayBadge.cjs +0 -27
- package/electron/trayHelper +0 -0
- package/electron/trayHelper.swift +0 -152
- package/electron/updateService.cjs +0 -220
- package/electron-builder.yml +0 -20
- /package/dist/client/assets/{index-CY4G_b0x.js → index-C913wKtU.js} +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { readStoredCredential } from '../credentialsFile.js';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { QuotaError, baseSnapshot } from '../adapter.js';
|
|
6
|
+
import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromCounts } from '../helpers.js';
|
|
7
|
+
/**
|
|
8
|
+
* Kimi Code adapter.
|
|
9
|
+
*
|
|
10
|
+
* Source: `GET https://api.kimi.com/coding/v1/usages`, Bearer auth with the
|
|
11
|
+
* OAuth token stored by the Kimi CLI at ~/.kimi/credentials/kimi-code.json.
|
|
12
|
+
* Returns a primary weekly `usage` block plus an arbitrary-length `limits[]`
|
|
13
|
+
* array of windows (e.g. a 5-hour rolling window).
|
|
14
|
+
*
|
|
15
|
+
* Token lifetime is ~1h, so we refresh it from auth.kimi.com before it
|
|
16
|
+
* expires and persist the new tokens back to the same file (the Kimi CLI
|
|
17
|
+
* reads the same file, so both stay in sync).
|
|
18
|
+
*/
|
|
19
|
+
const KIMI_CLIENT_ID = '17e5f671-d194-4dfb-9706-5516cb48c098';
|
|
20
|
+
const KIMI_BASE = 'https://api.kimi.com/coding/v1';
|
|
21
|
+
const KIMI_AUTH = 'https://auth.kimi.com/api/oauth/token';
|
|
22
|
+
export const kimiAdapter = {
|
|
23
|
+
provider: 'kimi',
|
|
24
|
+
displayName: 'Kimi Code',
|
|
25
|
+
async isConfigured() {
|
|
26
|
+
const cred = readCredentials();
|
|
27
|
+
return !!cred && !!cred.access_token;
|
|
28
|
+
},
|
|
29
|
+
async fetch(options) {
|
|
30
|
+
const credPath = credentialsPath();
|
|
31
|
+
let cred = options?.credential?.apiKey
|
|
32
|
+
? { access_token: options.credential.apiKey, token_type: 'Bearer' }
|
|
33
|
+
: readCredentials();
|
|
34
|
+
if (!cred || !cred.access_token) {
|
|
35
|
+
throw new QuotaError({ state: 'not_configured', message: 'run `kimi` to log in first' });
|
|
36
|
+
}
|
|
37
|
+
// Refresh proactively if within 5 min of expiry.
|
|
38
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
39
|
+
if (cred.expires_at && cred.expires_at - nowSec < 300 && cred.refresh_token) {
|
|
40
|
+
cred = await refreshToken(credPath, cred.refresh_token).catch(() => cred);
|
|
41
|
+
}
|
|
42
|
+
let data;
|
|
43
|
+
try {
|
|
44
|
+
data = (await fetchJsonWithTimeout(`${KIMI_BASE}/usages`, {
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: `Bearer ${cred.access_token}`,
|
|
47
|
+
Accept: 'application/json',
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw classifyFetchError(err);
|
|
53
|
+
}
|
|
54
|
+
const windows = [];
|
|
55
|
+
// Primary weekly usage.
|
|
56
|
+
if (data.usage) {
|
|
57
|
+
const { used, limit } = toCounts(data.usage);
|
|
58
|
+
if (limit > 0) {
|
|
59
|
+
windows.push(windowFromCounts('kimi_weekly', 'Weekly', used, limit, {
|
|
60
|
+
durationMins: 10080,
|
|
61
|
+
resetsAt: normalizeIso(data.usage.resetTime),
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Arbitrary-length limits[] (e.g. 5-hour rolling window).
|
|
66
|
+
for (let i = 0; i < (data.limits?.length ?? 0); i++) {
|
|
67
|
+
const entry = data.limits[i];
|
|
68
|
+
const detail = entry?.detail;
|
|
69
|
+
if (!detail)
|
|
70
|
+
continue;
|
|
71
|
+
const { used, limit } = toCounts(detail);
|
|
72
|
+
if (limit <= 0)
|
|
73
|
+
continue;
|
|
74
|
+
const mins = minutesForWindow(entry?.window);
|
|
75
|
+
windows.push(windowFromCounts(`kimi_limit_${i}`, mins ? `${durationLabel(mins)} Window` : `Window ${i + 1}`, used, limit, {
|
|
76
|
+
durationMins: mins,
|
|
77
|
+
resetsAt: normalizeIso(detail.resetTime),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
const snap = baseSnapshot('kimi', 'Kimi Code', {
|
|
81
|
+
planName: data.user?.membership?.level ? prettifyLevel(data.user.membership.level) : undefined,
|
|
82
|
+
windows,
|
|
83
|
+
});
|
|
84
|
+
return { ...snap, status: { state: 'ok' } };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
function classifyFetchError(err) {
|
|
88
|
+
if (err instanceof HttpError) {
|
|
89
|
+
const c = classifyHttpError(err);
|
|
90
|
+
return new QuotaError(c);
|
|
91
|
+
}
|
|
92
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
93
|
+
return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
|
|
94
|
+
}
|
|
95
|
+
/** Kimi returns limit/remaining as STRINGS — coerce and invert to used. */
|
|
96
|
+
function toCounts(detail) {
|
|
97
|
+
const limit = toNumber(detail.limit);
|
|
98
|
+
const remaining = toNumber(detail.remaining);
|
|
99
|
+
if (limit <= 0)
|
|
100
|
+
return { used: 0, limit: 0 };
|
|
101
|
+
return { used: Math.max(0, limit - remaining), limit };
|
|
102
|
+
}
|
|
103
|
+
function toNumber(v) {
|
|
104
|
+
if (v === undefined || v === null)
|
|
105
|
+
return 0;
|
|
106
|
+
const n = typeof v === 'string' ? parseFloat(v) : v;
|
|
107
|
+
return Number.isFinite(n) ? n : 0;
|
|
108
|
+
}
|
|
109
|
+
function minutesForWindow(window) {
|
|
110
|
+
if (!window?.duration)
|
|
111
|
+
return undefined;
|
|
112
|
+
const unit = (window.timeUnit || '').toUpperCase();
|
|
113
|
+
if (unit.includes('HOUR'))
|
|
114
|
+
return window.duration * 60;
|
|
115
|
+
if (unit.includes('DAY'))
|
|
116
|
+
return window.duration * 1440;
|
|
117
|
+
return window.duration; // default minutes
|
|
118
|
+
}
|
|
119
|
+
function durationLabel(mins) {
|
|
120
|
+
if (mins >= 10080)
|
|
121
|
+
return 'Weekly';
|
|
122
|
+
if (mins >= 1440)
|
|
123
|
+
return `${Math.round(mins / 1440)}-Day`;
|
|
124
|
+
if (mins >= 60)
|
|
125
|
+
return `${Math.round(mins / 60)}-Hour`;
|
|
126
|
+
return `${mins}m`;
|
|
127
|
+
}
|
|
128
|
+
function normalizeIso(s) {
|
|
129
|
+
return s ? new Date(s).toISOString() : undefined;
|
|
130
|
+
}
|
|
131
|
+
function prettifyLevel(level) {
|
|
132
|
+
// "LEVEL_INTERMEDIATE" -> "Intermediate"
|
|
133
|
+
return level.replace(/^LEVEL_/, '').toLowerCase().replace(/(^|_)(\w)/g, (_, __, c) => ' ' + c.toUpperCase()).trim();
|
|
134
|
+
}
|
|
135
|
+
function kimiDataDir() {
|
|
136
|
+
return process.env.KIMI_DATA_DIR || join(homedir(), '.kimi');
|
|
137
|
+
}
|
|
138
|
+
function credentialsPath() {
|
|
139
|
+
return join(kimiDataDir(), 'credentials', 'kimi-code.json');
|
|
140
|
+
}
|
|
141
|
+
function readCredentials() {
|
|
142
|
+
// 0. Token entered in-app (via the credential sheet) — highest priority.
|
|
143
|
+
// Treated as a bare access token; no refresh token, so it's used as-is.
|
|
144
|
+
const stored = readStoredCredential('kimi');
|
|
145
|
+
if (stored?.apiKey) {
|
|
146
|
+
return { access_token: stored.apiKey, token_type: 'Bearer' };
|
|
147
|
+
}
|
|
148
|
+
const path = credentialsPath();
|
|
149
|
+
if (!existsSync(path))
|
|
150
|
+
return null;
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function refreshToken(credPath, refreshToken) {
|
|
159
|
+
const body = new URLSearchParams({
|
|
160
|
+
client_id: KIMI_CLIENT_ID,
|
|
161
|
+
grant_type: 'refresh_token',
|
|
162
|
+
refresh_token: refreshToken,
|
|
163
|
+
});
|
|
164
|
+
const res = await fetch(KIMI_AUTH, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
167
|
+
body,
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok)
|
|
170
|
+
throw new Error(`token refresh failed: HTTP ${res.status}`);
|
|
171
|
+
const tokens = (await res.json());
|
|
172
|
+
const updated = {
|
|
173
|
+
access_token: tokens.access_token,
|
|
174
|
+
refresh_token: tokens.refresh_token || refreshToken,
|
|
175
|
+
expires_at: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600),
|
|
176
|
+
token_type: tokens.token_type || 'Bearer',
|
|
177
|
+
};
|
|
178
|
+
// Persist back so the Kimi CLI and this adapter share the refreshed token.
|
|
179
|
+
try {
|
|
180
|
+
writeFileSync(credPath, JSON.stringify(updated, null, 2), 'utf8');
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Best-effort; the in-memory token still works for this request.
|
|
184
|
+
}
|
|
185
|
+
return updated;
|
|
186
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { QuotaError, baseSnapshot } from '../adapter.js';
|
|
2
|
+
import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromCounts, unixToIso } from '../helpers.js';
|
|
3
|
+
import { readStoredCredential } from '../credentialsFile.js';
|
|
4
|
+
export const minimaxAdapter = {
|
|
5
|
+
provider: 'minimax',
|
|
6
|
+
displayName: 'MiniMax Coding Plan',
|
|
7
|
+
async isConfigured() {
|
|
8
|
+
return !!resolveCredential();
|
|
9
|
+
},
|
|
10
|
+
async fetch(options) {
|
|
11
|
+
const cred = resolveCredential(options?.credential);
|
|
12
|
+
if (!cred) {
|
|
13
|
+
throw new QuotaError({ state: 'not_configured', message: 'set MINIMAX_API_KEY (Subscription Key)' });
|
|
14
|
+
}
|
|
15
|
+
let data;
|
|
16
|
+
try {
|
|
17
|
+
data = (await fetchJsonWithTimeout(`${cred.base}/v1/token_plan/remains`, {
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: `Bearer ${cred.key}`,
|
|
20
|
+
'Content-Type': 'application/json',
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
throw classifyFetchError(err);
|
|
26
|
+
}
|
|
27
|
+
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
|
|
28
|
+
throw new QuotaError({
|
|
29
|
+
state: 'upstream_unavailable',
|
|
30
|
+
message: data.base_resp.status_msg || `MiniMax error ${data.base_resp.status_code}`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const windows = [];
|
|
34
|
+
for (const m of data.model_remains ?? []) {
|
|
35
|
+
const model = m.model_name ?? 'MiniMax';
|
|
36
|
+
const intervalTotal = m.current_interval_total_count ?? 0;
|
|
37
|
+
const intervalRemaining = m.current_interval_usage_count ?? 0;
|
|
38
|
+
// used = total - remaining (the "*_usage_count" fields are misnamed).
|
|
39
|
+
if (intervalTotal > 0) {
|
|
40
|
+
windows.push(windowFromCounts(`minimax_5h_${model}`, `5-Hour · ${model}`, Math.max(0, intervalTotal - intervalRemaining), intervalTotal, { durationMins: 300, resetsAt: unixToIso(m.end_time) }));
|
|
41
|
+
}
|
|
42
|
+
const weeklyTotal = m.current_weekly_total_count ?? 0;
|
|
43
|
+
const weeklyRemaining = m.current_weekly_usage_count ?? 0;
|
|
44
|
+
if (weeklyTotal > 0) {
|
|
45
|
+
windows.push(windowFromCounts(`minimax_weekly_${model}`, `Weekly · ${model}`, Math.max(0, weeklyTotal - weeklyRemaining), weeklyTotal, { durationMins: 10080 }));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const snap = baseSnapshot('minimax', 'MiniMax Coding Plan', { windows });
|
|
49
|
+
return { ...snap, status: { state: 'ok' } };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
function classifyFetchError(err) {
|
|
53
|
+
if (err instanceof HttpError) {
|
|
54
|
+
const c = classifyHttpError(err);
|
|
55
|
+
return new QuotaError(c);
|
|
56
|
+
}
|
|
57
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
58
|
+
return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
|
|
59
|
+
}
|
|
60
|
+
function resolveCredential(proposed) {
|
|
61
|
+
if (proposed?.apiKey) {
|
|
62
|
+
const region = (process.env.MINIMAX_REGION || '').toLowerCase();
|
|
63
|
+
const base = proposed.baseUrl || (region === 'cn' ? 'https://www.minimaxi.com' : 'https://www.minimax.io');
|
|
64
|
+
return { key: proposed.apiKey, base };
|
|
65
|
+
}
|
|
66
|
+
// 0. Key entered in-app (via the credential sheet) — highest priority.
|
|
67
|
+
const stored = readStoredCredential('minimax');
|
|
68
|
+
if (stored) {
|
|
69
|
+
const region = (process.env.MINIMAX_REGION || '').toLowerCase();
|
|
70
|
+
const base = stored.baseUrl || (region === 'cn' ? 'https://www.minimaxi.com' : 'https://www.minimax.io');
|
|
71
|
+
return { key: stored.apiKey, base };
|
|
72
|
+
}
|
|
73
|
+
const key = process.env.MINIMAX_API_KEY || process.env.MINIMAX_SUBSCRIPTION_KEY;
|
|
74
|
+
if (!key)
|
|
75
|
+
return null;
|
|
76
|
+
// minimax.io = global, minimaxi.com = China.
|
|
77
|
+
const region = (process.env.MINIMAX_REGION || '').toLowerCase();
|
|
78
|
+
const base = region === 'cn'
|
|
79
|
+
? (process.env.MINIMAX_BASE_URL || 'https://www.minimaxi.com')
|
|
80
|
+
: (process.env.MINIMAX_BASE_URL || 'https://www.minimax.io');
|
|
81
|
+
return { key, base };
|
|
82
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { QuotaSnapshot } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-provider quota cache.
|
|
4
|
+
*
|
|
5
|
+
* Keeps the last successful snapshot so a transient refresh failure returns
|
|
6
|
+
* stale-but-useful data (marked freshness "stale") instead of erasing it.
|
|
7
|
+
* The cache is in-memory only — quota snapshots are live and short-lived,
|
|
8
|
+
* so disk persistence (unlike the usage cache) adds no value.
|
|
9
|
+
*/
|
|
10
|
+
export declare class QuotaCache {
|
|
11
|
+
private readonly ttlMs;
|
|
12
|
+
private readonly store;
|
|
13
|
+
constructor(ttlMs?: number);
|
|
14
|
+
/** Fresh cached snapshot, or null if expired / absent. */
|
|
15
|
+
getFresh(provider: string): QuotaSnapshot | null;
|
|
16
|
+
/** Last successful snapshot regardless of TTL (for stale-while-revalidate). */
|
|
17
|
+
getStale(provider: string): QuotaSnapshot | null;
|
|
18
|
+
set(snapshot: QuotaSnapshot): void;
|
|
19
|
+
clear(provider?: string): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider quota cache.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the last successful snapshot so a transient refresh failure returns
|
|
5
|
+
* stale-but-useful data (marked freshness "stale") instead of erasing it.
|
|
6
|
+
* The cache is in-memory only — quota snapshots are live and short-lived,
|
|
7
|
+
* so disk persistence (unlike the usage cache) adds no value.
|
|
8
|
+
*/
|
|
9
|
+
export class QuotaCache {
|
|
10
|
+
ttlMs;
|
|
11
|
+
store = new Map();
|
|
12
|
+
constructor(ttlMs = 60_000) {
|
|
13
|
+
this.ttlMs = ttlMs;
|
|
14
|
+
}
|
|
15
|
+
/** Fresh cached snapshot, or null if expired / absent. */
|
|
16
|
+
getFresh(provider) {
|
|
17
|
+
const entry = this.store.get(provider);
|
|
18
|
+
if (!entry)
|
|
19
|
+
return null;
|
|
20
|
+
if (Date.now() > entry.expiresAt)
|
|
21
|
+
return null;
|
|
22
|
+
return { ...entry.snapshot, freshness: 'cached' };
|
|
23
|
+
}
|
|
24
|
+
/** Last successful snapshot regardless of TTL (for stale-while-revalidate). */
|
|
25
|
+
getStale(provider) {
|
|
26
|
+
const entry = this.store.get(provider);
|
|
27
|
+
if (!entry)
|
|
28
|
+
return null;
|
|
29
|
+
return { ...entry.snapshot, freshness: 'stale' };
|
|
30
|
+
}
|
|
31
|
+
set(snapshot) {
|
|
32
|
+
this.store.set(snapshot.provider, {
|
|
33
|
+
snapshot,
|
|
34
|
+
expiresAt: Date.now() + this.ttlMs,
|
|
35
|
+
updatedAt: Date.now(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
clear(provider) {
|
|
39
|
+
if (provider)
|
|
40
|
+
this.store.delete(provider);
|
|
41
|
+
else
|
|
42
|
+
this.store.clear();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the cross-process credential bridge file written by the Swift app's
|
|
3
|
+
* CredentialSheet (~/.tokendash/credentials.json). Adapters check this FIRST —
|
|
4
|
+
* before env vars and settings.json — so a key entered in-app wins.
|
|
5
|
+
*
|
|
6
|
+
* Read fresh on each call (no cache) so a credential saved via the sheet takes
|
|
7
|
+
* effect on the very next quota refresh. The file is tiny, so the cost is nil.
|
|
8
|
+
*/
|
|
9
|
+
export interface StoredCredential {
|
|
10
|
+
apiKey: string;
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function readStoredCredential(provider: string): StoredCredential | null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
export function readStoredCredential(provider) {
|
|
5
|
+
try {
|
|
6
|
+
const path = join(homedir(), '.tokendash', 'credentials.json');
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
return null;
|
|
9
|
+
const all = JSON.parse(readFileSync(path, 'utf8'));
|
|
10
|
+
const entry = all?.[provider];
|
|
11
|
+
if (entry && typeof entry === 'object' && typeof entry.apiKey === 'string') {
|
|
12
|
+
const apiKey = entry.apiKey;
|
|
13
|
+
if (!apiKey)
|
|
14
|
+
return null;
|
|
15
|
+
const baseUrl = entry.baseUrl;
|
|
16
|
+
return { apiKey, baseUrl: typeof baseUrl === 'string' ? baseUrl : undefined };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { QuotaWindow } from './types.js';
|
|
2
|
+
/** Shared helpers for provider adapters. */
|
|
3
|
+
/** Convert a Unix timestamp (seconds or ms) to ISO 8601, or undefined. */
|
|
4
|
+
export declare function unixToIso(unix: number | string | null | undefined): string | undefined;
|
|
5
|
+
/** Clamp a percentage to 0-100. */
|
|
6
|
+
export declare function clampPercent(v: number): number;
|
|
7
|
+
/** Build a QuotaWindow from used/limit percentages. */
|
|
8
|
+
export declare function windowFromPercent(id: string, label: string, usedPercent: number, opts?: {
|
|
9
|
+
durationMins?: number;
|
|
10
|
+
resetsAt?: string;
|
|
11
|
+
used?: number;
|
|
12
|
+
limit?: number;
|
|
13
|
+
modelName?: string;
|
|
14
|
+
isUnlimited?: boolean;
|
|
15
|
+
}): QuotaWindow;
|
|
16
|
+
/** Build a QuotaWindow from absolute used/limit counts. */
|
|
17
|
+
export declare function windowFromCounts(id: string, label: string, used: number, limit: number, opts?: {
|
|
18
|
+
durationMins?: number;
|
|
19
|
+
resetsAt?: string;
|
|
20
|
+
modelName?: string;
|
|
21
|
+
}): QuotaWindow;
|
|
22
|
+
/**
|
|
23
|
+
* Fetch JSON with a timeout. Adapters that hit HTTP APIs use this so they
|
|
24
|
+
* share redaction + abort behavior. Returns parsed JSON or throws.
|
|
25
|
+
*/
|
|
26
|
+
export declare function fetchJsonWithTimeout(url: string, opts?: {
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}): Promise<unknown>;
|
|
30
|
+
export declare class HttpError extends Error {
|
|
31
|
+
readonly status: number;
|
|
32
|
+
readonly body: string;
|
|
33
|
+
constructor(status: number, body: string);
|
|
34
|
+
}
|
|
35
|
+
/** Map an HTTP status to a coarse auth/upstream classification. */
|
|
36
|
+
export declare function classifyHttpError(err: HttpError): {
|
|
37
|
+
state: 'auth_failed' | 'rate_limited' | 'upstream_unavailable';
|
|
38
|
+
message: string;
|
|
39
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Shared helpers for provider adapters. */
|
|
2
|
+
/** Convert a Unix timestamp (seconds or ms) to ISO 8601, or undefined. */
|
|
3
|
+
export function unixToIso(unix) {
|
|
4
|
+
if (unix === null || unix === undefined || unix === '')
|
|
5
|
+
return undefined;
|
|
6
|
+
const n = typeof unix === 'string' ? parseInt(unix, 10) : unix;
|
|
7
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
8
|
+
return undefined;
|
|
9
|
+
// Codex/MiniMax use seconds or ms; disambiguate by magnitude.
|
|
10
|
+
const ms = n > 1e12 ? n : n * 1000;
|
|
11
|
+
return new Date(ms).toISOString();
|
|
12
|
+
}
|
|
13
|
+
/** Clamp a percentage to 0-100. */
|
|
14
|
+
export function clampPercent(v) {
|
|
15
|
+
if (!Number.isFinite(v))
|
|
16
|
+
return 0;
|
|
17
|
+
return Math.max(0, Math.min(100, v));
|
|
18
|
+
}
|
|
19
|
+
/** Round to 1 decimal place so 61.1999... → 61.2, integers unchanged. */
|
|
20
|
+
function round1(v) {
|
|
21
|
+
return Math.round(v * 10) / 10;
|
|
22
|
+
}
|
|
23
|
+
/** Build a QuotaWindow from used/limit percentages. */
|
|
24
|
+
export function windowFromPercent(id, label, usedPercent, opts = {}) {
|
|
25
|
+
const used = round1(clampPercent(usedPercent));
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
label,
|
|
29
|
+
usedPercent: used,
|
|
30
|
+
remainingPercent: round1(100 - used),
|
|
31
|
+
durationMins: opts.durationMins,
|
|
32
|
+
resetsAt: opts.resetsAt,
|
|
33
|
+
used: opts.used,
|
|
34
|
+
limit: opts.limit,
|
|
35
|
+
modelName: opts.modelName,
|
|
36
|
+
isUnlimited: opts.isUnlimited,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Build a QuotaWindow from absolute used/limit counts. */
|
|
40
|
+
export function windowFromCounts(id, label, used, limit, opts = {}) {
|
|
41
|
+
if (limit <= 0) {
|
|
42
|
+
return { id, label, usedPercent: 0, remainingPercent: 100, used, limit, isUnlimited: true, ...opts };
|
|
43
|
+
}
|
|
44
|
+
const pct = round1(clampPercent((used / limit) * 100));
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
label,
|
|
48
|
+
usedPercent: pct,
|
|
49
|
+
remainingPercent: round1(100 - pct),
|
|
50
|
+
used,
|
|
51
|
+
limit,
|
|
52
|
+
...opts,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Fetch JSON with a timeout. Adapters that hit HTTP APIs use this so they
|
|
57
|
+
* share redaction + abort behavior. Returns parsed JSON or throws.
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchJsonWithTimeout(url, opts = {}) {
|
|
60
|
+
const ctrl = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 8_000);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(url, { headers: opts.headers, signal: ctrl.signal });
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const body = await res.text().catch(() => '');
|
|
66
|
+
throw new HttpError(res.status, body.slice(0, 200));
|
|
67
|
+
}
|
|
68
|
+
return await res.json();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export class HttpError extends Error {
|
|
75
|
+
status;
|
|
76
|
+
body;
|
|
77
|
+
constructor(status, body) {
|
|
78
|
+
super(`HTTP ${status}`);
|
|
79
|
+
this.status = status;
|
|
80
|
+
this.body = body;
|
|
81
|
+
this.name = 'HttpError';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Map an HTTP status to a coarse auth/upstream classification. */
|
|
85
|
+
export function classifyHttpError(err) {
|
|
86
|
+
if (err.status === 401 || err.status === 403) {
|
|
87
|
+
return { state: 'auth_failed', message: 'credential rejected by provider' };
|
|
88
|
+
}
|
|
89
|
+
if (err.status === 429) {
|
|
90
|
+
return { state: 'rate_limited', message: 'provider throttled the request' };
|
|
91
|
+
}
|
|
92
|
+
return { state: 'upstream_unavailable', message: `provider returned HTTP ${err.status}` };
|
|
93
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { QuotaCache } from './cache.js';
|
|
2
|
+
import { QuotaService } from './quotaService.js';
|
|
3
|
+
export type { QuotaCredentialInput, QuotaCredentialValidation, QuotaSnapshot, QuotaWindow, QuotaProviderStatus, QuotaResponse, QuotaProviderId, } from './types.js';
|
|
4
|
+
export declare const quotaCache: QuotaCache;
|
|
5
|
+
export declare const quotaService: QuotaService;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota module entry point.
|
|
3
|
+
*
|
|
4
|
+
* Builds the singleton adapter registry + service. The rest of the server
|
|
5
|
+
* imports `quotaService` from here; adapters are wired in registration order,
|
|
6
|
+
* which becomes the dashboard display order.
|
|
7
|
+
*/
|
|
8
|
+
import { QuotaAdapterRegistry } from './adapter.js';
|
|
9
|
+
import { QuotaCache } from './cache.js';
|
|
10
|
+
import { QuotaService } from './quotaService.js';
|
|
11
|
+
import { codexAdapter } from './adapters/codex.js';
|
|
12
|
+
import { claudeAdapter } from './adapters/claude.js';
|
|
13
|
+
import { glmAdapter } from './adapters/glm.js';
|
|
14
|
+
import { minimaxAdapter } from './adapters/minimax.js';
|
|
15
|
+
import { kimiAdapter } from './adapters/kimi.js';
|
|
16
|
+
const registry = new QuotaAdapterRegistry();
|
|
17
|
+
registry.register(claudeAdapter);
|
|
18
|
+
registry.register(codexAdapter);
|
|
19
|
+
registry.register(glmAdapter);
|
|
20
|
+
registry.register(minimaxAdapter);
|
|
21
|
+
registry.register(kimiAdapter);
|
|
22
|
+
export const quotaCache = new QuotaCache();
|
|
23
|
+
export const quotaService = new QuotaService(registry, quotaCache);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { QuotaCredentialInput, QuotaCredentialValidation, QuotaSnapshot, QuotaProviderId, QuotaResponse } from './types.js';
|
|
2
|
+
import type { QuotaAdapterRegistry } from './adapter.js';
|
|
3
|
+
import { QuotaCache } from './cache.js';
|
|
4
|
+
/**
|
|
5
|
+
* Deep quota service. Owns discovery, concurrency, deduplication, caching,
|
|
6
|
+
* stale-while-revalidate, timeouts, and error classification. Adapters only
|
|
7
|
+
* detect + fetch + normalize; everything cross-cutting lives here.
|
|
8
|
+
*/
|
|
9
|
+
export declare class QuotaService {
|
|
10
|
+
private readonly registry;
|
|
11
|
+
private readonly cache;
|
|
12
|
+
private readonly configuredCache;
|
|
13
|
+
/** Cap concurrent upstream calls so a slow provider can't block the others. */
|
|
14
|
+
private readonly fetchTimeoutMs;
|
|
15
|
+
/** In-flight promises keyed by provider id, to dedupe concurrent requests. */
|
|
16
|
+
private readonly inflight;
|
|
17
|
+
constructor(registry: QuotaAdapterRegistry, cache?: QuotaCache, configuredCache?: QuotaProviderId[] | null, fetchTimeoutMs?: number);
|
|
18
|
+
/**
|
|
19
|
+
* List provider ids that are configured locally. Cheap (no network).
|
|
20
|
+
* The dashboard only shows these — not-configured providers are excluded.
|
|
21
|
+
*/
|
|
22
|
+
discover(): Promise<QuotaProviderId[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Fetch one provider's snapshot. Fresh if available; stale-but-retained
|
|
25
|
+
* on failure; never throws (errors become structured statuses).
|
|
26
|
+
*/
|
|
27
|
+
fetchOne(provider: QuotaProviderId): Promise<QuotaSnapshot | null>;
|
|
28
|
+
/**
|
|
29
|
+
* Fetch all configured providers concurrently. Partial success: one
|
|
30
|
+
* provider's failure never breaks the others. Order = registry order.
|
|
31
|
+
*/
|
|
32
|
+
fetchAll(): Promise<QuotaResponse>;
|
|
33
|
+
/** Force a refresh of all configured providers, bypassing the cache. */
|
|
34
|
+
refreshAll(): Promise<QuotaResponse>;
|
|
35
|
+
/**
|
|
36
|
+
* Validate a credential without caching it or writing it to disk. This keeps
|
|
37
|
+
* the settings form transactional: only credentials accepted upstream are
|
|
38
|
+
* persisted by the native app.
|
|
39
|
+
*/
|
|
40
|
+
validateCredential(provider: QuotaProviderId, credential: QuotaCredentialInput): Promise<QuotaCredentialValidation>;
|
|
41
|
+
private fetchWithTimeout;
|
|
42
|
+
private handleFailure;
|
|
43
|
+
}
|