@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/dist/server/index.js
CHANGED
|
@@ -146,6 +146,7 @@ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir)
|
|
|
146
146
|
export function createApp(_port, baseDir) {
|
|
147
147
|
const app = express();
|
|
148
148
|
const router = express.Router();
|
|
149
|
+
app.use(express.json({ limit: '16kb' }));
|
|
149
150
|
// Register API routes
|
|
150
151
|
registerApiRoutes(router, {
|
|
151
152
|
packageName: PACKAGE_NAME,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QuotaProviderId, QuotaSnapshot, QuotaProviderStatus } from './types.js';
|
|
1
|
+
import type { QuotaCredentialInput, QuotaProviderId, QuotaSnapshot, QuotaProviderStatus } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Structured quota error. Adapters throw this (not generic Error) so the
|
|
4
4
|
* service can classify the status without inspecting message strings.
|
|
@@ -28,7 +28,9 @@ export interface QuotaAdapter {
|
|
|
28
28
|
* Fetch a fresh normalized snapshot. Throws QuotaError on any failure.
|
|
29
29
|
* Must NOT include secrets in any field of the returned snapshot.
|
|
30
30
|
*/
|
|
31
|
-
fetch(
|
|
31
|
+
fetch(options?: {
|
|
32
|
+
credential?: QuotaCredentialInput;
|
|
33
|
+
}): Promise<QuotaSnapshot>;
|
|
32
34
|
}
|
|
33
35
|
/**
|
|
34
36
|
* Registry of all known adapters, keyed by provider id.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
3
|
+
import { homedir, userInfo } from 'node:os';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
import { QuotaError, baseSnapshot } from '../adapter.js';
|
|
6
7
|
import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromPercent } from '../helpers.js';
|
|
7
8
|
export const claudeAdapter = {
|
|
@@ -77,7 +78,7 @@ function readClaudeToken() {
|
|
|
77
78
|
if (existsSync(credPath)) {
|
|
78
79
|
try {
|
|
79
80
|
const parsed = JSON.parse(readFileSync(credPath, 'utf8'));
|
|
80
|
-
return parsed
|
|
81
|
+
return extractClaudeAccessToken(parsed);
|
|
81
82
|
}
|
|
82
83
|
catch {
|
|
83
84
|
return null;
|
|
@@ -86,39 +87,66 @@ function readClaudeToken() {
|
|
|
86
87
|
return null;
|
|
87
88
|
}
|
|
88
89
|
function readFromKeychain() {
|
|
89
|
-
|
|
90
|
-
// name first, then any matching suffix.
|
|
91
|
-
const candidates = ['Claude Code-credentials'];
|
|
90
|
+
const candidates = claudeKeychainServiceNames(process.env.CLAUDE_CONFIG_DIR);
|
|
92
91
|
try {
|
|
93
92
|
const list = execFileSync('security', ['dump-keychain'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
|
|
94
|
-
for (const m of list.matchAll(/"
|
|
93
|
+
for (const m of list.matchAll(/"svce"<blob>="([^"]*Claude Code-credentials[^"]*)"/g)) {
|
|
95
94
|
if (m[1] && !candidates.includes(m[1]))
|
|
96
95
|
candidates.push(m[1]);
|
|
97
96
|
}
|
|
98
97
|
}
|
|
99
98
|
catch {
|
|
100
|
-
// dump-keychain may
|
|
99
|
+
// dump-keychain may fail; deterministic service names are still tried.
|
|
101
100
|
}
|
|
101
|
+
const accounts = [safeUsername(), undefined];
|
|
102
102
|
for (const name of candidates) {
|
|
103
|
-
|
|
104
|
-
const raw = execFileSync('security', ['find-generic-password', '-s', name, '-w'], {
|
|
105
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
106
|
-
encoding: 'utf8',
|
|
107
|
-
}).trim();
|
|
108
|
-
if (!raw)
|
|
109
|
-
continue;
|
|
110
|
-
// The stored value is itself a JSON blob holding the OAuth tokens.
|
|
103
|
+
for (const account of accounts) {
|
|
111
104
|
try {
|
|
112
|
-
const
|
|
113
|
-
|
|
105
|
+
const args = ['find-generic-password', '-s', name];
|
|
106
|
+
if (account)
|
|
107
|
+
args.push('-a', account);
|
|
108
|
+
args.push('-w');
|
|
109
|
+
const raw = execFileSync('/usr/bin/security', args, {
|
|
110
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
timeout: 2_000,
|
|
113
|
+
}).trim();
|
|
114
|
+
if (!raw)
|
|
115
|
+
continue;
|
|
116
|
+
const token = extractClaudeAccessToken(JSON.parse(raw));
|
|
117
|
+
if (token)
|
|
118
|
+
return token;
|
|
114
119
|
}
|
|
115
120
|
catch {
|
|
116
|
-
|
|
121
|
+
continue;
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
|
-
catch {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
124
|
}
|
|
123
125
|
return null;
|
|
124
126
|
}
|
|
127
|
+
export function claudeKeychainServiceNames(configDir) {
|
|
128
|
+
if (!configDir)
|
|
129
|
+
return ['Claude Code-credentials'];
|
|
130
|
+
const hash = createHash('sha256').update(configDir).digest('hex').slice(0, 8);
|
|
131
|
+
return [`Claude Code-credentials-${hash}`, 'Claude Code-credentials'];
|
|
132
|
+
}
|
|
133
|
+
export function extractClaudeAccessToken(value) {
|
|
134
|
+
if (!value || typeof value !== 'object')
|
|
135
|
+
return null;
|
|
136
|
+
const root = value;
|
|
137
|
+
const nested = root.claudeAiOauth;
|
|
138
|
+
const credentials = nested && typeof nested === 'object'
|
|
139
|
+
? nested
|
|
140
|
+
: root;
|
|
141
|
+
return typeof credentials.accessToken === 'string' && credentials.accessToken
|
|
142
|
+
? credentials.accessToken
|
|
143
|
+
: null;
|
|
144
|
+
}
|
|
145
|
+
function safeUsername() {
|
|
146
|
+
try {
|
|
147
|
+
return userInfo().username?.trim() || undefined;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
import type { QuotaAdapter } from '../adapter.js';
|
|
2
2
|
export declare const codexAdapter: QuotaAdapter;
|
|
3
|
+
interface CodexBinaryResolutionOptions {
|
|
4
|
+
home?: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
explicitBinary?: string;
|
|
7
|
+
isExecutable?: (candidate: string) => boolean;
|
|
8
|
+
nvmVersions?: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve Codex independently of the launch environment. Finder-launched apps
|
|
12
|
+
* normally receive only /usr/bin:/bin:/usr/sbin:/sbin, while Codex is commonly
|
|
13
|
+
* installed in Codex.app, Homebrew, Volta, or an NVM version directory.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveCodexBinary(options?: CodexBinaryResolutionOptions): string | null;
|
|
16
|
+
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, readdirSync, accessSync, constants } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { QuotaError, baseSnapshot } from '../adapter.js';
|
|
@@ -11,12 +11,10 @@ export const codexAdapter = {
|
|
|
11
11
|
provider: 'codex',
|
|
12
12
|
displayName: 'OpenAI Codex',
|
|
13
13
|
async isConfigured() {
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
return true;
|
|
19
|
-
return await codexBinaryAvailable();
|
|
14
|
+
// Only surface Codex when the official login file and a runnable official
|
|
15
|
+
// CLI are both present. Finder-launched apps have a minimal PATH, so binary
|
|
16
|
+
// discovery must not rely on `which codex`.
|
|
17
|
+
return existsSync(join(codexHome(), 'auth.json')) && resolveCodexBinary() !== null;
|
|
20
18
|
},
|
|
21
19
|
async fetch() {
|
|
22
20
|
const result = await queryRateLimits();
|
|
@@ -64,10 +62,16 @@ function capitalize(s) {
|
|
|
64
62
|
}
|
|
65
63
|
// --- JSON-RPC over the codex app-server ---
|
|
66
64
|
async function queryRateLimits() {
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
const codexBinary = resolveCodexBinary();
|
|
66
|
+
if (!codexBinary) {
|
|
67
|
+
throw new QuotaError({ state: 'not_configured', message: 'official Codex CLI not found' });
|
|
69
68
|
}
|
|
70
|
-
const
|
|
69
|
+
const binaryDir = dirname(codexBinary);
|
|
70
|
+
const childPath = [binaryDir, process.env.PATH].filter(Boolean).join(':');
|
|
71
|
+
const proc = spawn(codexBinary, ['app-server'], {
|
|
72
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
73
|
+
env: { ...process.env, PATH: childPath },
|
|
74
|
+
});
|
|
71
75
|
const client = new JsonRpcClient(proc);
|
|
72
76
|
try {
|
|
73
77
|
await client.request('initialize', {
|
|
@@ -169,20 +173,54 @@ class JsonRpcClient {
|
|
|
169
173
|
this.pending.clear();
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Resolve Codex independently of the launch environment. Finder-launched apps
|
|
178
|
+
* normally receive only /usr/bin:/bin:/usr/sbin:/sbin, while Codex is commonly
|
|
179
|
+
* installed in Codex.app, Homebrew, Volta, or an NVM version directory.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveCodexBinary(options = {}) {
|
|
182
|
+
const home = options.home ?? homedir();
|
|
183
|
+
const path = options.path ?? process.env.PATH ?? '';
|
|
184
|
+
const explicitBinary = options.explicitBinary ?? process.env.CODEX_BIN;
|
|
185
|
+
const isExecutable = options.isExecutable ?? defaultIsExecutable;
|
|
186
|
+
const nvmRoot = join(home, '.nvm', 'versions', 'node');
|
|
187
|
+
const nvmVersions = options.nvmVersions ?? readDirectoryNames(nvmRoot);
|
|
188
|
+
const candidates = [
|
|
189
|
+
explicitBinary,
|
|
190
|
+
'/Applications/Codex.app/Contents/Resources/codex',
|
|
191
|
+
join(home, 'Applications', 'Codex.app', 'Contents', 'Resources', 'codex'),
|
|
192
|
+
'/opt/homebrew/bin/codex',
|
|
193
|
+
'/usr/local/bin/codex',
|
|
194
|
+
join(home, '.local', 'bin', 'codex'),
|
|
195
|
+
join(home, '.volta', 'bin', 'codex'),
|
|
196
|
+
join(home, '.bun', 'bin', 'codex'),
|
|
197
|
+
...nvmVersions
|
|
198
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
|
|
199
|
+
.map((version) => join(nvmRoot, version, 'bin', 'codex')),
|
|
200
|
+
...path.split(':').filter(Boolean).map((directory) => join(directory, 'codex')),
|
|
201
|
+
];
|
|
202
|
+
for (const candidate of candidates) {
|
|
203
|
+
if (candidate && isExecutable(candidate))
|
|
204
|
+
return candidate;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
function defaultIsExecutable(candidate) {
|
|
209
|
+
try {
|
|
210
|
+
accessSync(candidate, constants.X_OK);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function readDirectoryNames(directory) {
|
|
218
|
+
try {
|
|
219
|
+
return readdirSync(directory, { withFileTypes: true })
|
|
220
|
+
.filter((entry) => entry.isDirectory())
|
|
221
|
+
.map((entry) => entry.name);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
186
226
|
}
|
|
187
|
-
// Keep readFileSync referenced for potential future auth.json inspection without network.
|
|
188
|
-
void readFileSync;
|
|
@@ -10,8 +10,8 @@ export const glmAdapter = {
|
|
|
10
10
|
async isConfigured() {
|
|
11
11
|
return !!resolveCredential();
|
|
12
12
|
},
|
|
13
|
-
async fetch() {
|
|
14
|
-
const cred = resolveCredential();
|
|
13
|
+
async fetch(options) {
|
|
14
|
+
const cred = resolveCredential(options?.credential);
|
|
15
15
|
if (!cred) {
|
|
16
16
|
throw new QuotaError({ state: 'not_configured', message: 'set ZAI_API_KEY or ZHIPU_API_KEY' });
|
|
17
17
|
}
|
|
@@ -63,7 +63,13 @@ function classifyFetchError(err) {
|
|
|
63
63
|
const msg = err instanceof Error ? err.message : String(err);
|
|
64
64
|
return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
|
|
65
65
|
}
|
|
66
|
-
function resolveCredential() {
|
|
66
|
+
function resolveCredential(proposed) {
|
|
67
|
+
if (proposed?.apiKey) {
|
|
68
|
+
return {
|
|
69
|
+
key: proposed.apiKey,
|
|
70
|
+
base: proposed.baseUrl || 'https://open.bigmodel.cn',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
67
73
|
// 0. Key entered in-app (via the credential sheet) — highest priority.
|
|
68
74
|
const stored = readStoredCredential('glm');
|
|
69
75
|
if (stored)
|
|
@@ -26,9 +26,11 @@ export const kimiAdapter = {
|
|
|
26
26
|
const cred = readCredentials();
|
|
27
27
|
return !!cred && !!cred.access_token;
|
|
28
28
|
},
|
|
29
|
-
async fetch() {
|
|
29
|
+
async fetch(options) {
|
|
30
30
|
const credPath = credentialsPath();
|
|
31
|
-
let cred =
|
|
31
|
+
let cred = options?.credential?.apiKey
|
|
32
|
+
? { access_token: options.credential.apiKey, token_type: 'Bearer' }
|
|
33
|
+
: readCredentials();
|
|
32
34
|
if (!cred || !cred.access_token) {
|
|
33
35
|
throw new QuotaError({ state: 'not_configured', message: 'run `kimi` to log in first' });
|
|
34
36
|
}
|
|
@@ -7,8 +7,8 @@ export const minimaxAdapter = {
|
|
|
7
7
|
async isConfigured() {
|
|
8
8
|
return !!resolveCredential();
|
|
9
9
|
},
|
|
10
|
-
async fetch() {
|
|
11
|
-
const cred = resolveCredential();
|
|
10
|
+
async fetch(options) {
|
|
11
|
+
const cred = resolveCredential(options?.credential);
|
|
12
12
|
if (!cred) {
|
|
13
13
|
throw new QuotaError({ state: 'not_configured', message: 'set MINIMAX_API_KEY (Subscription Key)' });
|
|
14
14
|
}
|
|
@@ -57,7 +57,12 @@ function classifyFetchError(err) {
|
|
|
57
57
|
const msg = err instanceof Error ? err.message : String(err);
|
|
58
58
|
return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
|
|
59
59
|
}
|
|
60
|
-
function resolveCredential() {
|
|
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
|
+
}
|
|
61
66
|
// 0. Key entered in-app (via the credential sheet) — highest priority.
|
|
62
67
|
const stored = readStoredCredential('minimax');
|
|
63
68
|
if (stored) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { QuotaCache } from './cache.js';
|
|
2
2
|
import { QuotaService } from './quotaService.js';
|
|
3
|
-
export type { QuotaSnapshot, QuotaWindow, QuotaProviderStatus, QuotaResponse, QuotaProviderId } from './types.js';
|
|
3
|
+
export type { QuotaCredentialInput, QuotaCredentialValidation, QuotaSnapshot, QuotaWindow, QuotaProviderStatus, QuotaResponse, QuotaProviderId, } from './types.js';
|
|
4
4
|
export declare const quotaCache: QuotaCache;
|
|
5
5
|
export declare const quotaService: QuotaService;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QuotaSnapshot, QuotaProviderId, QuotaResponse } from './types.js';
|
|
1
|
+
import type { QuotaCredentialInput, QuotaCredentialValidation, QuotaSnapshot, QuotaProviderId, QuotaResponse } from './types.js';
|
|
2
2
|
import type { QuotaAdapterRegistry } from './adapter.js';
|
|
3
3
|
import { QuotaCache } from './cache.js';
|
|
4
4
|
/**
|
|
@@ -32,6 +32,12 @@ export declare class QuotaService {
|
|
|
32
32
|
fetchAll(): Promise<QuotaResponse>;
|
|
33
33
|
/** Force a refresh of all configured providers, bypassing the cache. */
|
|
34
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>;
|
|
35
41
|
private fetchWithTimeout;
|
|
36
42
|
private handleFailure;
|
|
37
43
|
}
|
|
@@ -74,6 +74,29 @@ export class QuotaService {
|
|
|
74
74
|
}
|
|
75
75
|
return this.fetchAll();
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Validate a credential without caching it or writing it to disk. This keeps
|
|
79
|
+
* the settings form transactional: only credentials accepted upstream are
|
|
80
|
+
* persisted by the native app.
|
|
81
|
+
*/
|
|
82
|
+
async validateCredential(provider, credential) {
|
|
83
|
+
const adapter = this.registry.get(provider);
|
|
84
|
+
if (!adapter) {
|
|
85
|
+
return {
|
|
86
|
+
provider,
|
|
87
|
+
valid: false,
|
|
88
|
+
status: { state: 'not_configured', message: 'Unsupported provider' },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const snapshot = await withTimeout(adapter.fetch({ credential }), this.fetchTimeoutMs, provider);
|
|
93
|
+
const validated = validateQuotaSnapshot(snapshot);
|
|
94
|
+
return { provider, valid: validated.status.state === 'ok', status: validated.status };
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
return { provider, valid: false, status: statusForError(err, this.fetchTimeoutMs) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
77
100
|
async fetchWithTimeout(adapter) {
|
|
78
101
|
try {
|
|
79
102
|
const snapshot = await withTimeout(adapter.fetch(), this.fetchTimeoutMs, adapter.provider);
|
|
@@ -86,16 +109,7 @@ export class QuotaService {
|
|
|
86
109
|
}
|
|
87
110
|
}
|
|
88
111
|
handleFailure(adapter, err) {
|
|
89
|
-
|
|
90
|
-
if (err instanceof QuotaError) {
|
|
91
|
-
status = err.status;
|
|
92
|
-
}
|
|
93
|
-
else if (err instanceof TimeoutError) {
|
|
94
|
-
status = { state: 'timed_out', message: `upstream did not respond within ${this.fetchTimeoutMs}ms` };
|
|
95
|
-
}
|
|
96
|
-
else {
|
|
97
|
-
status = { state: 'error', message: redact(err), category: 'unexpected' };
|
|
98
|
-
}
|
|
112
|
+
const status = statusForError(err, this.fetchTimeoutMs);
|
|
99
113
|
// Retain last good snapshot as stale.
|
|
100
114
|
const stale = this.cache.getStale(adapter.provider);
|
|
101
115
|
if (stale) {
|
|
@@ -112,6 +126,14 @@ export class QuotaService {
|
|
|
112
126
|
};
|
|
113
127
|
}
|
|
114
128
|
}
|
|
129
|
+
function statusForError(err, timeoutMs) {
|
|
130
|
+
if (err instanceof QuotaError)
|
|
131
|
+
return err.status;
|
|
132
|
+
if (err instanceof TimeoutError) {
|
|
133
|
+
return { state: 'timed_out', message: `upstream did not respond within ${timeoutMs}ms` };
|
|
134
|
+
}
|
|
135
|
+
return { state: 'error', message: redact(err), category: 'unexpected' };
|
|
136
|
+
}
|
|
115
137
|
class TimeoutError extends Error {
|
|
116
138
|
constructor(provider) {
|
|
117
139
|
super(`quota fetch timed out: ${provider}`);
|
|
@@ -63,3 +63,14 @@ export interface QuotaProviderStatus {
|
|
|
63
63
|
export interface QuotaResponse {
|
|
64
64
|
providers: QuotaSnapshot[];
|
|
65
65
|
}
|
|
66
|
+
/** A credential proposed by the settings UI but not persisted yet. */
|
|
67
|
+
export interface QuotaCredentialInput {
|
|
68
|
+
apiKey: string;
|
|
69
|
+
baseUrl?: string;
|
|
70
|
+
}
|
|
71
|
+
/** Result of checking a proposed credential against its upstream provider. */
|
|
72
|
+
export interface QuotaCredentialValidation {
|
|
73
|
+
provider: QuotaProviderId;
|
|
74
|
+
valid: boolean;
|
|
75
|
+
status: QuotaProviderStatus;
|
|
76
|
+
}
|
|
@@ -21,6 +21,24 @@ async function getQuota(_req, res) {
|
|
|
21
21
|
res.status(500).json({ error: 'Failed to fetch quota', hint: message });
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
const editableQuotaProviders = new Set(['glm', 'kimi', 'minimax']);
|
|
25
|
+
async function validateQuotaCredential(req, res) {
|
|
26
|
+
const provider = req.body?.provider;
|
|
27
|
+
const apiKey = typeof req.body?.apiKey === 'string' ? req.body.apiKey.trim() : '';
|
|
28
|
+
const baseUrl = typeof req.body?.baseUrl === 'string' ? req.body.baseUrl.trim() : undefined;
|
|
29
|
+
if (!editableQuotaProviders.has(provider) || !apiKey) {
|
|
30
|
+
res.status(400).json({
|
|
31
|
+
error: 'Invalid credential request',
|
|
32
|
+
hint: 'A supported provider and non-empty token are required.',
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const result = await quotaService.validateCredential(provider, {
|
|
37
|
+
apiKey,
|
|
38
|
+
baseUrl: baseUrl || undefined,
|
|
39
|
+
});
|
|
40
|
+
res.status(result.valid ? 200 : 422).json(result);
|
|
41
|
+
}
|
|
24
42
|
function getAgents(_req, res) {
|
|
25
43
|
try {
|
|
26
44
|
const agents = detectAvailableAgents();
|
|
@@ -59,4 +77,5 @@ export function registerApiRoutes(router, appInfo) {
|
|
|
59
77
|
router.get('/blocks', getBlocks);
|
|
60
78
|
router.get('/analytics', getAnalytics);
|
|
61
79
|
router.get('/quota', getQuota);
|
|
80
|
+
router.post('/quota/validate', validateQuotaCredential);
|
|
62
81
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhangferry-dev/tokendash",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Token Usage Analytics Dashboard",
|
|
6
6
|
"publishConfig": {
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"test:watch": "vitest",
|
|
31
31
|
"test:e2e": "playwright test",
|
|
32
32
|
"prepack": "npm run build",
|
|
33
|
-
"release": "npm publish --access public --registry https://registry.npmjs.org"
|
|
33
|
+
"release:npm": "npm publish --access public --registry https://registry.npmjs.org",
|
|
34
|
+
"deploy:check": "./scripts/deploy.sh --dry-run",
|
|
35
|
+
"deploy": "./scripts/deploy.sh"
|
|
34
36
|
},
|
|
35
37
|
"dependencies": {
|
|
36
38
|
"express": "^5.1.0",
|