@alilis/k-hat 0.2.0 → 0.2.2
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 +2 -2
- package/dist/admin.js +50 -9
- package/dist/cli.js +58 -19
- package/dist/config.js +7 -0
- package/dist/router.js +13 -5
- package/dist/selector.js +1 -1
- package/dist/server.js +13 -5
- package/dist/store.js +41 -0
- package/dist/tui-client.js +5 -0
- package/dist/web-ui.js +83 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,8 +86,8 @@ khat stop
|
|
|
86
86
|
|
|
87
87
|
| 命令 | 说明 |
|
|
88
88
|
|---|---|
|
|
89
|
-
| `khat init` | 首次初始化:创建密钥库 + 访问令牌 +
|
|
90
|
-
| `khat start` | 后台启动代理与 supervisor;使用 `--foreground`
|
|
89
|
+
| `khat init` | 首次初始化:创建密钥库 + 访问令牌 + 默认配置;可用 `--port <n>` 指定监听端口 |
|
|
90
|
+
| `khat start` | 后台启动代理与 supervisor;使用 `--foreground` 以前台模式诊断,使用 `--port <n>` 修改并持久化监听端口 |
|
|
91
91
|
| `khat stop` | 请求后台代理优雅停止 |
|
|
92
92
|
| `khat status` | 查看 Provider、key、路由及 key 健康状态 |
|
|
93
93
|
| `khat doctor` | 检测本机 Agent Tool 配置并输出接入提示 |
|
package/dist/admin.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addProvider, updateProvider, removeProvider, addKey, updateKey, removeKey, enableKey, addRoute, updateRoute, removeRoute, maskSecret } from './store.js';
|
|
1
|
+
import { addProvider, updateProvider, removeProvider, addKey, updateKey, removeKey, enableKey, setKeyEnabled, addRoute, updateRoute, setRouteEnabled, removeRoute, maskSecret } from './store.js';
|
|
2
2
|
import { LogWriter } from './logger.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { UiSessions, serveUi } from './web-ui.js';
|
|
@@ -8,6 +8,15 @@ import { ACCESS_TOKEN_REF, generateAccessToken } from './vault.js';
|
|
|
8
8
|
// client or a process without the token cannot mutate config/state/vault.
|
|
9
9
|
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
10
10
|
const uiSessions = new UiSessions();
|
|
11
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
12
|
+
function upstreamHeaders(protocol, secret, contentType = false) {
|
|
13
|
+
const headers = protocol === 'anthropic'
|
|
14
|
+
? { 'x-api-key': secret, 'anthropic-version': ANTHROPIC_VERSION }
|
|
15
|
+
: { authorization: `Bearer ${secret}` };
|
|
16
|
+
if (contentType)
|
|
17
|
+
headers['content-type'] = 'application/json';
|
|
18
|
+
return headers;
|
|
19
|
+
}
|
|
11
20
|
function json(res, status, value) {
|
|
12
21
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
13
22
|
res.end(JSON.stringify(value));
|
|
@@ -23,6 +32,25 @@ async function readJsonBody(req) {
|
|
|
23
32
|
throw new Error('Invalid JSON body');
|
|
24
33
|
}
|
|
25
34
|
}
|
|
35
|
+
async function listProviderModels(store, providerId) {
|
|
36
|
+
const provider = store.config.providers.find((item) => item.id === providerId);
|
|
37
|
+
if (!provider)
|
|
38
|
+
throw new Error(`unknown provider: ${providerId}`);
|
|
39
|
+
const key = provider.keys.find((item) => item.enabled !== false && store.states[`${provider.id}/${item.id}`]?.status !== 'unavailable');
|
|
40
|
+
if (!key)
|
|
41
|
+
throw new Error('provider has no available key');
|
|
42
|
+
const secret = store.vault.get(key.vaultRef);
|
|
43
|
+
if (!secret)
|
|
44
|
+
throw new Error('provider key secret is missing');
|
|
45
|
+
const headers = upstreamHeaders(provider.protocol, secret);
|
|
46
|
+
const response = await fetch(new URL('/v1/models', provider.baseUrl), { headers, signal: AbortSignal.timeout(10_000) });
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new Error(`model query failed with HTTP ${response.status}`);
|
|
49
|
+
const body = await response.json();
|
|
50
|
+
const values = Array.isArray(body?.data) ? body.data : Array.isArray(body?.models) ? body.models : [];
|
|
51
|
+
const ids = values.map((item) => typeof item === 'string' ? item : item?.id).filter((id) => typeof id === 'string' && id.length > 0);
|
|
52
|
+
return Array.from(new Set(ids)).sort();
|
|
53
|
+
}
|
|
26
54
|
function buildStatus(store) {
|
|
27
55
|
const token = store.vault.get(ACCESS_TOKEN_REF);
|
|
28
56
|
return {
|
|
@@ -39,6 +67,7 @@ function buildStatus(store) {
|
|
|
39
67
|
return {
|
|
40
68
|
id: key.id,
|
|
41
69
|
weight: key.weight,
|
|
70
|
+
...(key.enabled === false ? { enabled: false } : {}),
|
|
42
71
|
status: state?.status === 'unavailable' ? 'unavailable' : 'available',
|
|
43
72
|
lastError: state?.lastError,
|
|
44
73
|
secret: secret ? maskSecret(secret) : null,
|
|
@@ -46,7 +75,7 @@ function buildStatus(store) {
|
|
|
46
75
|
};
|
|
47
76
|
})
|
|
48
77
|
})),
|
|
49
|
-
routes: store.config.routes
|
|
78
|
+
routes: store.config.routes.map((route) => route.enabled === false ? { ...route, enabled: false } : { model: route.model, provider: route.provider })
|
|
50
79
|
};
|
|
51
80
|
}
|
|
52
81
|
async function route(req, res, store, sessionAuthorized = false) {
|
|
@@ -91,6 +120,8 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
91
120
|
return json(res, 200, { logs: logs.filter((entry) => (!model || entry.model === model) && (!key || entry.key === key) && (!status || String(entry.status) === status)) });
|
|
92
121
|
}
|
|
93
122
|
if (sub[0] === 'providers') {
|
|
123
|
+
if (sub.length === 3 && sub[2] === 'models' && method === 'GET')
|
|
124
|
+
return json(res, 200, { models: await listProviderModels(store, sub[1]) });
|
|
94
125
|
if (sub.length === 1 && method === 'POST') {
|
|
95
126
|
const body = await readJsonBody(req);
|
|
96
127
|
if (!body.id || !body.protocol || !body.baseUrl)
|
|
@@ -136,7 +167,11 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
136
167
|
return json(res, 200, { ok: true });
|
|
137
168
|
}
|
|
138
169
|
if (sub.length === 5 && sub[2] === 'keys' && sub[4] === 'enable' && method === 'POST') {
|
|
139
|
-
await store.mutate(() => enableKey(store, sub[1], sub[3]));
|
|
170
|
+
await store.mutate(async () => { await setKeyEnabled(store, sub[1], sub[3], true); await enableKey(store, sub[1], sub[3]); });
|
|
171
|
+
return json(res, 200, { ok: true });
|
|
172
|
+
}
|
|
173
|
+
if (sub.length === 5 && sub[2] === 'keys' && sub[4] === 'disable' && method === 'POST') {
|
|
174
|
+
await store.mutate(() => setKeyEnabled(store, sub[1], sub[3], false));
|
|
140
175
|
return json(res, 200, { ok: true });
|
|
141
176
|
}
|
|
142
177
|
if (sub.length === 5 && sub[2] === 'keys' && sub[4] === 'probe' && method === 'POST') {
|
|
@@ -149,7 +184,7 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
149
184
|
if (!secret || !model)
|
|
150
185
|
throw new Error('a key and route model are required for probing');
|
|
151
186
|
const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
|
|
152
|
-
const headers = provider.protocol
|
|
187
|
+
const headers = upstreamHeaders(provider.protocol, secret, true);
|
|
153
188
|
const body = provider.protocol === 'anthropic' ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] } : { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }], stream: false };
|
|
154
189
|
const response = await fetch(new URL(path, provider.baseUrl), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
|
|
155
190
|
if (response.ok)
|
|
@@ -167,14 +202,20 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
167
202
|
}
|
|
168
203
|
if (sub.length === 2 && method === 'PUT') {
|
|
169
204
|
const body = await readJsonBody(req);
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
205
|
+
if (body.provider !== undefined)
|
|
206
|
+
await store.mutate(() => updateRoute(store, decodeURIComponent(sub[1]), body.provider));
|
|
207
|
+
if (body.enabled !== undefined)
|
|
208
|
+
await store.mutate(() => setRouteEnabled(store, decodeURIComponent(sub[1]), body.enabled));
|
|
209
|
+
return json(res, 200, { ok: true });
|
|
210
|
+
}
|
|
211
|
+
if (sub.length === 2 && (sub[1] === 'enable' || sub[1] === 'disable') && method === 'POST') {
|
|
212
|
+
const model = url.searchParams.get('model');
|
|
213
|
+
if (!model)
|
|
214
|
+
throw new Error('model query parameter is required');
|
|
215
|
+
await store.mutate(() => setRouteEnabled(store, model, sub[1] === 'enable'));
|
|
173
216
|
return json(res, 200, { ok: true });
|
|
174
217
|
}
|
|
175
218
|
if (sub.length === 1 && method === 'DELETE') {
|
|
176
|
-
// The model goes in the query string: model names are not restricted to
|
|
177
|
-
// the provider/key id alphabet and may contain characters unsafe in a path segment.
|
|
178
219
|
const model = url.searchParams.get('model');
|
|
179
220
|
if (!model)
|
|
180
221
|
throw new Error('model query parameter is required');
|
package/dist/cli.js
CHANGED
|
@@ -27,14 +27,15 @@ Usage: khat <command> [arguments]
|
|
|
27
27
|
|
|
28
28
|
Setup
|
|
29
29
|
init first-time setup: create vault + access token + default config
|
|
30
|
-
start [--foreground]
|
|
30
|
+
start [--foreground] [--port <n>] start the proxy as a background daemon (or foreground with --foreground);
|
|
31
|
+
--port <n> changes the persisted listen port
|
|
31
32
|
stop stop the background daemon
|
|
32
33
|
status show providers, keys, routes and key health
|
|
33
34
|
doctor detect local Agent Tools and show connection guidance
|
|
34
35
|
log [--tail <n>] show recent masked proxy request logs
|
|
35
36
|
ui issue a one-time browser ticket for the management page
|
|
36
37
|
tui open the interactive terminal management UI
|
|
37
|
-
export <file> export encrypted portable vault
|
|
38
|
+
export <file> export encrypted portable vault (use a .khat suffix)
|
|
38
39
|
import <file> [--force] import encrypted portable vault
|
|
39
40
|
|
|
40
41
|
Access token
|
|
@@ -62,6 +63,9 @@ Routes
|
|
|
62
63
|
route list
|
|
63
64
|
route remove <model>
|
|
64
65
|
|
|
66
|
+
Version
|
|
67
|
+
version | --version | -v print the khat version
|
|
68
|
+
|
|
65
69
|
Environment
|
|
66
70
|
KHAT_HOME data directory (default ~/.khat)
|
|
67
71
|
KHAT_ACCESS_TOKEN override the vault access token (mainly for tests)`;
|
|
@@ -103,6 +107,15 @@ function flagInt(flags, name, fallback) {
|
|
|
103
107
|
throw new Error(`--${name} must be a positive integer`);
|
|
104
108
|
return parsed;
|
|
105
109
|
}
|
|
110
|
+
function flagPort(flags, name) {
|
|
111
|
+
const value = flagString(flags, name);
|
|
112
|
+
if (value === undefined)
|
|
113
|
+
return undefined;
|
|
114
|
+
const parsed = Number(value);
|
|
115
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535)
|
|
116
|
+
throw new Error(`--${name} must be an integer between 1 and 65535`);
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
106
119
|
async function promptSecret(label) {
|
|
107
120
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
108
121
|
try {
|
|
@@ -122,16 +135,26 @@ async function promptPassword(label) {
|
|
|
122
135
|
return new Promise((resolve, reject) => {
|
|
123
136
|
const stdin = process.stdin;
|
|
124
137
|
let value = '';
|
|
125
|
-
const onData = (chunk) => { const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
const onData = (chunk) => { for (const ch of chunk.toString()) {
|
|
139
|
+
if (ch === '\n' || ch === '\r') {
|
|
140
|
+
stdin.setRawMode?.(false);
|
|
141
|
+
stdin.pause();
|
|
142
|
+
stdin.off('data', onData);
|
|
143
|
+
process.stdout.write('\n');
|
|
144
|
+
value = value.trim();
|
|
145
|
+
return value ? resolve(value) : reject(new Error('empty password'));
|
|
146
|
+
}
|
|
147
|
+
if (ch === '\u007f' || ch === '\b') {
|
|
148
|
+
if (value) {
|
|
149
|
+
value = value.slice(0, -1);
|
|
150
|
+
process.stdout.write('\b \b');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
value += ch;
|
|
155
|
+
process.stdout.write('*');
|
|
156
|
+
}
|
|
157
|
+
} };
|
|
135
158
|
stdin.setRawMode?.(true);
|
|
136
159
|
stdin.resume();
|
|
137
160
|
stdin.on('data', onData);
|
|
@@ -244,10 +267,16 @@ async function runForeground() {
|
|
|
244
267
|
process.once('SIGINT', shutdown);
|
|
245
268
|
process.once('SIGTERM', shutdown);
|
|
246
269
|
}
|
|
247
|
-
async function runStart(foreground) {
|
|
270
|
+
async function runStart(foreground, port) {
|
|
271
|
+
const store = await openInitializedStore();
|
|
272
|
+
if (port !== undefined && port !== store.config.port) {
|
|
273
|
+
if (await daemonRunning())
|
|
274
|
+
throw new Error(`khat is already running on port ${store.config.port}; run 'khat stop' first, then start with --port ${port}`);
|
|
275
|
+
store.config.port = port;
|
|
276
|
+
await store.saveConfig();
|
|
277
|
+
}
|
|
248
278
|
if (foreground)
|
|
249
279
|
return runForeground();
|
|
250
|
-
const store = await openInitializedStore();
|
|
251
280
|
if (await daemonRunning())
|
|
252
281
|
throw new Error('khat is already running; use khat stop first');
|
|
253
282
|
const occupiedPid = await findListeningPid(store.config.port);
|
|
@@ -412,18 +441,23 @@ async function runImport(path, force) {
|
|
|
412
441
|
await importPortable(path, dataDir, createKeyProtector(), password, force);
|
|
413
442
|
console.log(`imported encrypted vault into ${dataDir}`);
|
|
414
443
|
}
|
|
415
|
-
async function
|
|
444
|
+
async function runVersion() {
|
|
445
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
446
|
+
console.log(pkg.version);
|
|
447
|
+
}
|
|
448
|
+
async function runInit(port) {
|
|
416
449
|
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
417
450
|
if (await fileExists(configPath)) {
|
|
418
451
|
console.error(`already initialized at ${configPath} (delete the directory to start over)`);
|
|
419
452
|
process.exitCode = 1;
|
|
420
453
|
return;
|
|
421
454
|
}
|
|
455
|
+
const config = port === undefined || port === defaultConfig.port ? defaultConfig : { ...defaultConfig, port };
|
|
422
456
|
const vault = await Vault.create(vaultPath, createKeyProtector());
|
|
423
457
|
const token = generateAccessToken();
|
|
424
458
|
vault.set(ACCESS_TOKEN_REF, token);
|
|
425
459
|
await vault.save();
|
|
426
|
-
await saveJsonAtomic(configPath,
|
|
460
|
+
await saveJsonAtomic(configPath, config);
|
|
427
461
|
await saveJsonAtomic(statePath, { keys: {} });
|
|
428
462
|
console.log(`Initialized ${dataDir}`);
|
|
429
463
|
console.log();
|
|
@@ -431,7 +465,7 @@ async function runInit() {
|
|
|
431
465
|
console.log(` ${token}`);
|
|
432
466
|
console.log();
|
|
433
467
|
console.log('Point your tools at the proxy, e.g.:');
|
|
434
|
-
console.log(` base URL: http://${
|
|
468
|
+
console.log(` base URL: http://${config.bind}:${config.port}/v1`);
|
|
435
469
|
console.log(` Authorization: Bearer ${token}`);
|
|
436
470
|
}
|
|
437
471
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -443,8 +477,13 @@ try {
|
|
|
443
477
|
case '-h':
|
|
444
478
|
console.log(USAGE);
|
|
445
479
|
break;
|
|
480
|
+
case 'version':
|
|
481
|
+
case '--version':
|
|
482
|
+
case '-v':
|
|
483
|
+
await runVersion();
|
|
484
|
+
break;
|
|
446
485
|
case 'init':
|
|
447
|
-
await runInit();
|
|
486
|
+
await runInit(flagPort(flags, 'port'));
|
|
448
487
|
break;
|
|
449
488
|
case 'export':
|
|
450
489
|
await runExport(positionals[0]);
|
|
@@ -453,7 +492,7 @@ try {
|
|
|
453
492
|
await runImport(positionals[0], flags.force === true);
|
|
454
493
|
break;
|
|
455
494
|
case 'start':
|
|
456
|
-
await runStart(flags.foreground === true);
|
|
495
|
+
await runStart(flags.foreground === true, flagPort(flags, 'port'));
|
|
457
496
|
break;
|
|
458
497
|
case 'stop':
|
|
459
498
|
await runStop();
|
package/dist/config.js
CHANGED
|
@@ -42,6 +42,9 @@ export function validateConfig(config) {
|
|
|
42
42
|
throw new Error(`Invalid timeouts.${field}: expected a positive integer`);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
|
|
46
|
+
throw new Error('Invalid config: port must be an integer between 1 and 65535');
|
|
47
|
+
}
|
|
45
48
|
for (const provider of config.providers) {
|
|
46
49
|
if (!provider.id || !provider.baseUrl || (provider.protocol !== 'openai' && provider.protocol !== 'anthropic') || !Array.isArray(provider.keys))
|
|
47
50
|
throw new Error(`Invalid provider: ${provider.id}`);
|
|
@@ -59,6 +62,8 @@ export function validateConfig(config) {
|
|
|
59
62
|
for (const key of provider.keys) {
|
|
60
63
|
if (!key.id || !Number.isInteger(key.weight) || key.weight < 1)
|
|
61
64
|
throw new Error(`Invalid key in provider ${provider.id}`);
|
|
65
|
+
if (key.enabled !== undefined && typeof key.enabled !== 'boolean')
|
|
66
|
+
throw new Error(`Invalid key ${provider.id}/${key.id}: enabled must be boolean`);
|
|
62
67
|
if (key.vaultRef !== `${provider.id}/${key.id}`)
|
|
63
68
|
throw new Error(`Invalid key ${provider.id}/${key.id}: vaultRef must match provider and key id`);
|
|
64
69
|
}
|
|
@@ -66,6 +71,8 @@ export function validateConfig(config) {
|
|
|
66
71
|
for (const route of config.routes) {
|
|
67
72
|
if (!route.model || !route.provider)
|
|
68
73
|
throw new Error('Invalid route: model and provider are required');
|
|
74
|
+
if (route.enabled !== undefined && typeof route.enabled !== 'boolean')
|
|
75
|
+
throw new Error(`Invalid route ${route.model}: enabled must be boolean`);
|
|
69
76
|
if (!config.providers.some((item) => item.id === route.provider))
|
|
70
77
|
throw new Error(`Invalid route ${route.model}: unknown provider ${route.provider}`);
|
|
71
78
|
}
|
package/dist/router.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
export function
|
|
1
|
+
export function findRoute(config, model) {
|
|
2
2
|
const slash = model.indexOf('/');
|
|
3
3
|
if (slash > 0) {
|
|
4
4
|
const providerId = model.slice(0, slash);
|
|
5
|
-
const
|
|
6
|
-
return
|
|
5
|
+
const routeModel = model.slice(slash + 1);
|
|
6
|
+
return config.routes.find((route) => route.provider === providerId && route.model === routeModel);
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
return config.routes.find((route) => route.model === model);
|
|
9
|
+
}
|
|
10
|
+
export function resolveProvider(config, model) {
|
|
11
|
+
const route = findRoute(config, model);
|
|
12
|
+
if (!route || route.enabled === false)
|
|
13
|
+
return undefined;
|
|
14
|
+
return config.providers.find((item) => item.id === route.provider);
|
|
15
|
+
}
|
|
16
|
+
export function isRouteDisabled(config, model) {
|
|
17
|
+
return findRoute(config, model)?.enabled === false;
|
|
10
18
|
}
|
package/dist/selector.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export class WeightedSelector {
|
|
2
2
|
current = new Map();
|
|
3
3
|
select(providerId, keys, states) {
|
|
4
|
-
const available = keys.filter((key) => states[`${providerId}/${key.id}`]?.status !== 'unavailable');
|
|
4
|
+
const available = keys.filter((key) => key.enabled !== false && states[`${providerId}/${key.id}`]?.status !== 'unavailable');
|
|
5
5
|
if (!available.length)
|
|
6
6
|
return undefined;
|
|
7
7
|
let total = 0;
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { watch } from 'node:fs';
|
|
|
3
3
|
import { readJsonFile, saveJsonAtomic, validateConfig, defaultTimeouts } from './config.js';
|
|
4
4
|
import { LogWriter } from './logger.js';
|
|
5
5
|
import { join, basename } from 'node:path';
|
|
6
|
-
import { resolveProvider } from './router.js';
|
|
6
|
+
import { resolveProvider, isRouteDisabled } from './router.js';
|
|
7
7
|
import { WeightedSelector } from './selector.js';
|
|
8
8
|
import { handleAdmin } from './admin.js';
|
|
9
9
|
import { ACCESS_TOKEN_REF } from './vault.js';
|
|
@@ -102,8 +102,11 @@ export function createKhatServer(options) {
|
|
|
102
102
|
if (!parsed.model || typeof parsed.model !== 'string')
|
|
103
103
|
return json(res, 400, { error: { message: 'model is required' } });
|
|
104
104
|
const provider = resolveProvider(options.config, parsed.model);
|
|
105
|
-
if (!provider)
|
|
105
|
+
if (!provider) {
|
|
106
|
+
if (isRouteDisabled(options.config, parsed.model))
|
|
107
|
+
return json(res, 403, { error: { message: `Route disabled for model: ${parsed.model}` } });
|
|
106
108
|
return json(res, 404, { error: { message: `No route for model: ${parsed.model}` } });
|
|
109
|
+
}
|
|
107
110
|
if (provider.protocol !== protocol)
|
|
108
111
|
return json(res, 400, { error: { message: `Model ${parsed.model} resolves to a ${provider.protocol} provider, but ${req.url} speaks ${protocol}` } });
|
|
109
112
|
const tried = new Set();
|
|
@@ -182,13 +185,18 @@ export function createKhatServer(options) {
|
|
|
182
185
|
clearTimeout(idleTimer);
|
|
183
186
|
}
|
|
184
187
|
}
|
|
185
|
-
res.end();
|
|
186
|
-
res.off('close', clientGone);
|
|
187
188
|
const durationMs = Date.now() - startedAt;
|
|
188
189
|
const ttfbMs = (firstByteAt ?? Date.now()) - startedAt;
|
|
189
190
|
const keyRef = `${provider.id}/${key.id}`;
|
|
190
191
|
options.store?.recordCounter(keyRef, { requests: 1, failed: upstream.ok ? 0 : 1, bytesOut: bytes, tokensIn, tokensOut });
|
|
191
|
-
|
|
192
|
+
try {
|
|
193
|
+
await logger?.append({ ts: new Date().toISOString(), event: 'forward', model: parsed.model, provider: provider.id, key: keyRef, status: upstream.status, ttfbMs, durationMs, bytes, tokensIn, tokensOut });
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
console.error(`[khat] failed to write request log: ${error?.message ?? error}`);
|
|
197
|
+
}
|
|
198
|
+
res.end();
|
|
199
|
+
res.off('close', clientGone);
|
|
192
200
|
return;
|
|
193
201
|
}
|
|
194
202
|
}
|
package/dist/store.js
CHANGED
|
@@ -195,6 +195,27 @@ export async function removeKey(store, providerId, keyId) {
|
|
|
195
195
|
await store.vault.save();
|
|
196
196
|
await store.saveState();
|
|
197
197
|
}
|
|
198
|
+
export async function setKeyEnabled(store, providerId, keyId, enabled) {
|
|
199
|
+
const provider = findProvider(store, providerId);
|
|
200
|
+
const key = provider.keys.find((item) => item.id === keyId);
|
|
201
|
+
if (!key)
|
|
202
|
+
throw new Error(`unknown key: ${providerId}/${keyId}`);
|
|
203
|
+
const previous = key.enabled;
|
|
204
|
+
if (enabled)
|
|
205
|
+
delete key.enabled;
|
|
206
|
+
else
|
|
207
|
+
key.enabled = false;
|
|
208
|
+
try {
|
|
209
|
+
await persistConfig(store);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (previous === undefined)
|
|
213
|
+
delete key.enabled;
|
|
214
|
+
else
|
|
215
|
+
key.enabled = previous;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
198
219
|
export async function enableKey(store, providerId, keyId) {
|
|
199
220
|
const provider = findProvider(store, providerId);
|
|
200
221
|
if (!provider.keys.some((item) => item.id === keyId))
|
|
@@ -231,6 +252,26 @@ export async function updateRoute(store, model, providerId) {
|
|
|
231
252
|
throw error;
|
|
232
253
|
}
|
|
233
254
|
}
|
|
255
|
+
export async function setRouteEnabled(store, model, enabled) {
|
|
256
|
+
const route = store.config.routes.find((item) => item.model === model);
|
|
257
|
+
if (!route)
|
|
258
|
+
throw new Error(`unknown route: ${model}`);
|
|
259
|
+
const previous = route.enabled;
|
|
260
|
+
if (enabled)
|
|
261
|
+
delete route.enabled;
|
|
262
|
+
else
|
|
263
|
+
route.enabled = false;
|
|
264
|
+
try {
|
|
265
|
+
await persistConfig(store);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (previous === undefined)
|
|
269
|
+
delete route.enabled;
|
|
270
|
+
else
|
|
271
|
+
route.enabled = previous;
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
234
275
|
export async function removeRoute(store, model) {
|
|
235
276
|
const before = store.config.routes.length;
|
|
236
277
|
store.config.routes = store.config.routes.filter((route) => route.model !== model);
|
package/dist/tui-client.js
CHANGED
|
@@ -51,6 +51,9 @@ export class AdminClient {
|
|
|
51
51
|
async deleteKey(providerId, keyId) {
|
|
52
52
|
await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE' });
|
|
53
53
|
}
|
|
54
|
+
async disableKey(providerId, keyId) {
|
|
55
|
+
await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/disable`, { method: 'POST' });
|
|
56
|
+
}
|
|
54
57
|
async enableKey(providerId, keyId) {
|
|
55
58
|
await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/enable`, { method: 'POST' });
|
|
56
59
|
}
|
|
@@ -63,6 +66,8 @@ export class AdminClient {
|
|
|
63
66
|
async updateRoute(model, provider) {
|
|
64
67
|
await this.request(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: { provider } });
|
|
65
68
|
}
|
|
69
|
+
async enableRoute(model) { await this.request(`/_keys/routes/enable?model=${encodeURIComponent(model)}`, { method: 'POST' }); }
|
|
70
|
+
async disableRoute(model) { await this.request(`/_keys/routes/disable?model=${encodeURIComponent(model)}`, { method: 'POST' }); }
|
|
66
71
|
async deleteRoute(model) {
|
|
67
72
|
await this.request(`/_keys/routes?model=${encodeURIComponent(model)}`, { method: 'DELETE' });
|
|
68
73
|
}
|
package/dist/web-ui.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
const PAGE = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>khat 密钥管理</title><script>(function(){try{var t=localStorage.getItem('khatTheme');if(t!=='dark'&&t!=='light')t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}})();</script><style>
|
|
2
|
+
const PAGE = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title data-i18n="title">khat 密钥管理</title><script>(function(){try{var t=localStorage.getItem('khatTheme');if(t!=='dark'&&t!=='light')t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}try{var l=localStorage.getItem('khatLang');document.documentElement.lang=(l==='en')?'en':'zh-CN'}catch(e){document.documentElement.lang='zh-CN'}})();</script><style>
|
|
3
3
|
*{box-sizing:border-box}
|
|
4
4
|
:root{color-scheme:light;--bg:#f4f6fa;--surface:#ffffff;--surface-2:var(--surface-2);--border:#e5eaf1;--border-strong:#d5dee9;--text:#1c2540;--muted:#5d6a85;--faint:#93a0b5;--accent:#4f46e5;--accent-hover:#4338ca;--accent-soft:#eef2ff;--ok:#15803d;--ok-bg:#ecfdf5;--ok-border:#bcf0d2;--bad:#b91c1c;--bad-bg:#fef2f2;--bad-border:#fdd8d8;--warn:#92400e;--warn-bg:#fffbeb;--warn-border:#fbe38a;--topbar-bg:rgba(255,255,255,.88);--ring:rgba(79,70,229,.16);--shadow:0 1px 2px rgba(23,32,51,.04);--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,'Courier New',monospace}
|
|
5
5
|
[data-theme=dark]{color-scheme:dark;--bg:#0f1523;--surface:#161d2f;--surface-2:#1d2538;--border:#28324a;--border-strong:#3a4767;--text:#e4eaf5;--muted:#9aa6bd;--faint:#68748f;--accent:#818cf8;--accent-hover:#a5b4fc;--accent-soft:#232c52;--ok:#4ade80;--ok-bg:#122b20;--ok-border:#215239;--bad:#f87171;--bad-bg:#33181a;--bad-border:#5c2a2e;--warn:#fbbf24;--warn-bg:#30260f;--warn-border:#5e4a17;--topbar-bg:rgba(15,21,35,.88);--ring:rgba(129,140,248,.32);--shadow:0 1px 3px rgba(0,0,0,.35)}
|
|
@@ -19,6 +19,13 @@ main.container{padding-top:22px;padding-bottom:48px}
|
|
|
19
19
|
.alert:not(:empty){display:block}
|
|
20
20
|
.alert-error{background:var(--bad-bg);color:var(--bad);border:1px solid var(--bad-border)}
|
|
21
21
|
.alert-warn{background:var(--warn-bg);color:var(--warn);border:1px solid var(--warn-border)}
|
|
22
|
+
.toasts{position:fixed;top:16px;right:16px;z-index:100;display:flex;flex-direction:column;gap:8px;max-width:min(360px,92vw);pointer-events:none}
|
|
23
|
+
.toast{pointer-events:auto;background:var(--surface);border:1px solid var(--border-strong);border-left-width:4px;border-radius:10px;padding:10px 14px;font-size:13px;line-height:1.45;box-shadow:0 8px 24px rgba(15,23,42,.18);animation:slideIn .22s ease;word-break:break-word}
|
|
24
|
+
.toast-error{border-left-color:var(--bad);color:var(--bad)}
|
|
25
|
+
.toast-ok{border-left-color:var(--ok);color:var(--ok)}
|
|
26
|
+
.toast-warn{border-left-color:var(--warn);color:var(--warn)}
|
|
27
|
+
.toast.fade{opacity:0;transform:translateX(12px);transition:opacity .3s,transform .3s}
|
|
28
|
+
@keyframes slideIn{from{opacity:0;transform:translateX(16px)}to{opacity:1;transform:translateX(0)}}
|
|
22
29
|
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-bottom:18px}
|
|
23
30
|
.stat{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 16px;box-shadow:var(--shadow)}
|
|
24
31
|
.stat-label{font-size:12px;font-weight:600;color:var(--muted)}
|
|
@@ -89,81 +96,73 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
89
96
|
#providerForm [name=baseUrl]{flex:1 1 220px;min-width:200px}
|
|
90
97
|
#keyForm [name=id]{width:170px}
|
|
91
98
|
#keyForm [name=value]{flex:1 1 220px;min-width:210px}
|
|
92
|
-
#routeForm [name=model]{width:230px;font-family:var(--mono)}
|
|
93
99
|
#providerForm>button,#keyForm>button,#routeForm>button{margin-left:auto}
|
|
94
|
-
dialog{border:none;border-radius:14px;padding:0;width:min(460px,92vw);box-shadow:0 20px 60px rgba(15,23,42,.28)}
|
|
95
|
-
dialog::backdrop{background:rgba(15,23,42,.45)}
|
|
96
|
-
.dialog-body{padding:22px 24px}
|
|
97
|
-
.dialog-title{margin:0 0 16px;font-size:15.5px;font-weight:700}
|
|
98
|
-
.field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px;font-size:12.5px;font-weight:600;color:var(--muted)}
|
|
99
|
-
.field input{height:36px}
|
|
100
|
-
.field-row{display:flex;gap:12px}
|
|
101
|
-
.field-row .field{flex:1;min-width:0}
|
|
102
|
-
.field-row input{width:100%}
|
|
103
|
-
.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
|
|
104
100
|
@media(max-width:640px){.auto-note{display:none}.conn-badge{max-width:180px}}
|
|
105
101
|
</style></head><body>
|
|
102
|
+
<div id="toasts" class="toasts"></div>
|
|
106
103
|
<header class="topbar"><div class="container topbar-inner">
|
|
107
104
|
<div class="brand"><span class="logo" aria-hidden="true"></span><span>khat</span><span id="connection" class="conn-badge"></span></div>
|
|
108
|
-
<div class="topbar-actions"><button id="
|
|
105
|
+
<div class="topbar-actions"><span class="auto-note" data-i18n="autoRefresh">每 30 秒自动刷新</span><button id="langToggle" class="btn btn-secondary btn-icon" type="button" data-i18n-aria="toggleLang" title="中文 / English">EN</button><button id="themeToggle" class="btn btn-secondary btn-icon" type="button" data-i18n-aria="toggleTheme" title="切换深色 / 浅色模式">🌙</button><button id="refresh" class="btn btn-secondary" type="button" data-i18n="refresh">刷新</button></div>
|
|
109
106
|
</div></header>
|
|
110
107
|
<main class="container">
|
|
111
108
|
<p id="error" class="alert alert-error"></p>
|
|
112
109
|
<p id="poolWarn" class="alert alert-warn"></p>
|
|
113
110
|
<div class="stats">
|
|
114
|
-
<div class="stat"><div class="stat-label">Provider 数量</div><div class="stat-value" id="statProviders">—</div><div class="stat-sub">已配置的服务商</div></div>
|
|
115
|
-
<div class="stat"><div class="stat-label">Key 可用率</div><div class="stat-value" id="statKeys">—</div><div class="stat-sub" id="statKeysSub">—</div></div>
|
|
116
|
-
<div class="stat"><div class="stat-label">近期请求成功率</div><div class="stat-value" id="statReq">—</div><div class="stat-sub" id="statReqSub">按最近一次拉取的日志统计</div></div>
|
|
117
|
-
<div class="stat"><div class="stat-label">近期 Tokens 用量</div><div class="stat-value" id="statTokens">—</div><div class="stat-sub">输入 / 输出 合计</div></div>
|
|
111
|
+
<div class="stat"><div class="stat-label" data-i18n="statProviders">Provider 数量</div><div class="stat-value" id="statProviders">—</div><div class="stat-sub" data-i18n="statProvidersSub">已配置的服务商</div></div>
|
|
112
|
+
<div class="stat"><div class="stat-label" data-i18n="statKeys">Key 可用率</div><div class="stat-value" id="statKeys">—</div><div class="stat-sub" id="statKeysSub">—</div></div>
|
|
113
|
+
<div class="stat"><div class="stat-label" data-i18n="statReq">近期请求成功率</div><div class="stat-value" id="statReq">—</div><div class="stat-sub" id="statReqSub" data-i18n="statReqSub">按最近一次拉取的日志统计</div></div>
|
|
114
|
+
<div class="stat"><div class="stat-label" data-i18n="statTokens">近期 Tokens 用量</div><div class="stat-value" id="statTokens">—</div><div class="stat-sub" data-i18n="statTokensSub">输入 / 输出 合计</div></div>
|
|
118
115
|
</div>
|
|
119
116
|
<section class="card">
|
|
120
|
-
<div class="card-head"><div><h2 class="card-title">Provider / Key 管理</h2><p class="card-sub">维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。</p></div></div>
|
|
121
|
-
<form id="providerForm" class="toolbar" autocomplete="off"><input name="id" placeholder="provider id" required><input name="name" placeholder="名称"><input name="baseUrl" placeholder="https://api.example.com" required><select name="protocol"><option>openai</option><option>anthropic</option></select><button class="btn btn-primary">添加 Provider</button></form>
|
|
122
|
-
<form id="keyForm" class="toolbar" autocomplete="off"><select name="provider" required></select><input name="id" placeholder="key id" required><input name="weight" type="number" min="1" step="1" value="1" title="
|
|
123
|
-
<div class="filter-bar"><input id="keySearch" type="search" placeholder="搜索 provider 名称 / baseUrl / key id / 错误原因…" aria-label="筛选 Key"><select id="keyStatus" aria-label="状态筛选"><option value="all">全部状态</option><option value="available">仅可用</option><option value="unavailable">仅不可用</option></select><span class="match-info" id="keyMatchInfo"></span></div>
|
|
117
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardProviders">Provider / Key 管理</h2><p class="card-sub" data-i18n="cardProvidersSub">维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。</p></div></div>
|
|
118
|
+
<form id="providerForm" class="toolbar" autocomplete="off"><input name="id" placeholder="provider id" data-i18n-ph="phProviderId" required><input name="name" placeholder="名称" data-i18n-ph="phName"><input name="baseUrl" placeholder="https://api.example.com" data-i18n-ph="phBaseUrl" required><select name="protocol"><option>openai</option><option>anthropic</option></select><button class="btn btn-primary" data-i18n="btnAddProvider">添加 Provider</button></form>
|
|
119
|
+
<form id="keyForm" class="toolbar" autocomplete="off"><select name="provider" required></select><input name="id" placeholder="key id" data-i18n-ph="phKeyId" required><input name="weight" type="number" min="1" step="1" value="1" data-i18n-title="weightTitle"><input name="value" type="password" placeholder="API key 明文(加密存入密钥库)" data-i18n-ph="phKeyValue" required autocomplete="new-password"><button class="btn btn-primary" data-i18n="btnAddKey">添加 Key</button></form>
|
|
120
|
+
<div class="filter-bar"><input id="keySearch" type="search" placeholder="搜索 provider 名称 / baseUrl / key id / 错误原因…" data-i18n-ph="phSearchKey" data-i18n-aria="keyFilterAria" aria-label="筛选 Key"><select id="keyStatus" data-i18n-aria="statusFilterAria" aria-label="状态筛选"><option value="all" data-i18n="allStatus">全部状态</option><option value="available" data-i18n="onlyAvail">仅可用</option><option value="unavailable" data-i18n="onlyUnavail">仅不可用</option></select><span class="match-info" id="keyMatchInfo"></span></div>
|
|
124
121
|
<div id="providers"></div>
|
|
125
122
|
</section>
|
|
126
123
|
<section class="card">
|
|
127
|
-
<div class="card-head"><div><h2 class="card-title">Routes</h2><p class="card-sub">模型到 Provider 的转发映射规则。</p></div></div>
|
|
128
|
-
<form id="routeForm" class="toolbar" autocomplete="off"><
|
|
129
|
-
<div class="table-wrap"><table><thead><tr><th>Model</th><th>Provider</th><th></th></tr></thead><tbody id="routes"></tbody></table></div>
|
|
124
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardRoutes">Routes</h2><p class="card-sub" data-i18n="cardRoutesSub">模型到 Provider 的转发映射规则。</p></div></div>
|
|
125
|
+
<form id="routeForm" class="toolbar" autocomplete="off"><select name="provider" required></select><select name="models" multiple size="5" required aria-label="models"></select><button type="button" id="routeModels" class="btn btn-secondary" data-i18n="btnLoadModels">获取模型</button><button class="btn btn-primary" data-i18n="btnAddRoute">批量添加 Route</button></form>
|
|
126
|
+
<div class="table-wrap"><table><thead><tr><th data-i18n="thModel">Model</th><th data-i18n="thProvider">Provider</th><th data-i18n="thStatus">Status</th><th></th></tr></thead><tbody id="routes"></tbody></table></div>
|
|
130
127
|
</section>
|
|
131
128
|
<section class="card">
|
|
132
|
-
<div class="card-head"><div><h2 class="card-title">请求成功 / 失败趋势</h2><p class="card-sub">将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。</p></div><span class="legend"><span><i class="swatch ok"></i>成功</span><span><i class="swatch bad"></i>失败</span></span></div>
|
|
133
|
-
<div class="chart-box"><svg id="traffic" role="img" aria-label="request success/failure trend"></svg><div id="chartEmpty" class="chart-empty" hidden>暂无请求记录</div></div>
|
|
129
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardTraffic">请求成功 / 失败趋势</h2><p class="card-sub" data-i18n="cardTrafficSub">将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。</p></div><span class="legend"><span><i class="swatch ok"></i><span data-i18n="legendOk">成功</span></span><span><i class="swatch bad"></i><span data-i18n="legendFail">失败</span></span></span></div>
|
|
130
|
+
<div class="chart-box"><svg id="traffic" role="img" data-i18n-aria="trafficAria" aria-label="request success/failure trend"></svg><div id="chartEmpty" class="chart-empty" hidden data-i18n="chartEmpty">暂无请求记录</div></div>
|
|
134
131
|
</section>
|
|
135
132
|
<section class="card">
|
|
136
|
-
<div class="card-head"><div><h2 class="card-title">Logs</h2><p class="card-sub">最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。</p></div></div>
|
|
137
|
-
<div class="filter-bar"><input id="logFilter" type="search" placeholder="按 model / key / provider / status 筛选…"><select id="logStatus" aria-label="结果筛选"><option value="all">全部结果</option><option value="ok">仅成功</option><option value="fail">仅失败</option></select><select id="logTail" aria-label="日志条数"><option value="100" selected>最近 100 条</option><option value="300">最近 300 条</option><option value="1000">最近 1000 条</option></select><span class="match-info" id="logMatchInfo"></span></div>
|
|
138
|
-
<div class="table-wrap"><table><thead><tr><th>Time</th><th>Status</th><th>Provider</th><th>Key</th><th>Model</th><th class="num">Duration</th><th class="num">Tokens in/out</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
133
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardLogs">Logs</h2><p class="card-sub" data-i18n="cardLogsSub">最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。</p></div></div>
|
|
134
|
+
<div class="filter-bar"><input id="logFilter" type="search" placeholder="按 model / key / provider / status 筛选…" data-i18n-ph="phFilterLog"><select id="logStatus" data-i18n-aria="resultFilterAria" aria-label="结果筛选"><option value="all" data-i18n="allResult">全部结果</option><option value="ok" data-i18n="onlyOk">仅成功</option><option value="fail" data-i18n="onlyFail">仅失败</option></select><select id="logTail" data-i18n-aria="logCountAria" aria-label="日志条数"><option value="100" selected data-i18n="logTail100">最近 100 条</option><option value="300" data-i18n="logTail300">最近 300 条</option><option value="1000" data-i18n="logTail1000">最近 1000 条</option></select><span class="match-info" id="logMatchInfo"></span></div>
|
|
135
|
+
<div class="table-wrap"><table><thead><tr><th data-i18n="thTime">Time</th><th data-i18n="thStatus">Status</th><th data-i18n="thProvider">Provider</th><th data-i18n="thKey">Key</th><th data-i18n="thModel">Model</th><th class="num" data-i18n="thDuration">Duration</th><th class="num" data-i18n="thTokensInOut">Tokens in/out</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
139
136
|
</section>
|
|
140
137
|
</main>
|
|
141
|
-
<dialog id="keyEdit"><div class="dialog-body"><h3 class="dialog-title">编辑 Key</h3><form id="keyEditForm" autocomplete="off">
|
|
142
|
-
<label class="field">Provider<input name="provider" readonly></label>
|
|
143
|
-
<label class="field">Key id<input name="id" readonly></label>
|
|
144
|
-
<div class="field-row"><label class="field">权重<input name="weight" type="number" min="1" step="1" value="1" title="轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比"></label><label class="field">新 API key(留空保持不变)<input name="value" type="password" autocomplete="new-password" placeholder="sk-…"></label></div>
|
|
145
|
-
<footer class="dialog-actions"><button type="button" id="keyEditCancel" class="btn btn-secondary">取消</button><button class="btn btn-primary">保存</button></footer>
|
|
146
|
-
</form></div></dialog>
|
|
147
138
|
<script type="module">
|
|
148
139
|
const $=s=>document.querySelector(s), error=$('#error');
|
|
149
140
|
const NS='http://www.w3.org/2000/svg';
|
|
150
141
|
const pad2=n=>String(n).padStart(2,'0');
|
|
151
142
|
const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all'};
|
|
152
|
-
|
|
153
|
-
|
|
143
|
+
const I18N={
|
|
144
|
+
zh:{title:'khat 密钥管理',toggleTheme:'切换深色 / 浅色模式',toggleLang:'切换语言',keyFilterAria:'筛选 Key',statusFilterAria:'状态筛选',resultFilterAria:'结果筛选',logCountAria:'日志条数',autoRefresh:'每 30 秒自动刷新',refresh:'刷新',statProviders:'Provider 数量',statProvidersSub:'已配置的服务商',statKeys:'Key 可用率',statReq:'近期请求成功率',statReqSub:'按最近一次拉取的日志统计',statTokens:'近期 Tokens 用量',statTokensSub:'输入 / 输出 合计',cardProviders:'Provider / Key 管理',cardProvidersSub:'维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。',phProviderId:'provider id',phName:'名称',phBaseUrl:'https://api.example.com',btnAddProvider:'添加 Provider',phKeyId:'key id',weightTitle:'轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比,默认 1',phKeyValue:'API key 明文(加密存入密钥库)',btnAddKey:'添加 Key',phSearchKey:'搜索 provider 名称 / baseUrl / key id / 错误原因…',allStatus:'全部状态',onlyAvail:'仅可用',onlyUnavail:'仅不可用',cardRoutes:'Routes',cardRoutesSub:'模型到 Provider 的转发映射规则。',phModel:'model',btnAddRoute:'批量添加 Route',btnLoadModels:'获取模型',thModel:'Model',thProvider:'Provider',cardTraffic:'请求成功 / 失败趋势',cardTrafficSub:'将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。',legendOk:'成功',legendFail:'失败',chartEmpty:'暂无请求记录',cardLogs:'Logs',cardLogsSub:'最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。',phFilterLog:'按 model / key / provider / status 筛选…',allResult:'全部结果',onlyOk:'仅成功',onlyFail:'仅失败',logTail100:'最近 100 条',logTail300:'最近 300 条',logTail1000:'最近 1000 条',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'错误原因',thActions:'操作',avail:'可用',unavail:'不可用',disabled:'已停用',noKeysMatch:'没有符合筛选条件的 key',noKeysYet:'该 Provider 尚未添加 key,可在上方表单中添加',keyMatchInfo:'匹配 {k} / {t} 个 key · 显示 {p} 个 Provider',noProviderMatch:'没有匹配的 Provider 或 Key,试试调整关键字或状态筛选。',noRoutes:'暂无路由配置',noLogs:'暂无请求日志',noLogMatch:'没有匹配的日志,试试调整筛选条件。',poolWarn:'Provider {p} 没有可用的 Key,请恢复或探活后再重试。',btnDisable:'停用',btnRecover:'恢复',btnProbe:'探活',btnDelete:'删除',confirmDelKey:'删除 key {p}/{k}?',saved:'已保存',errOpenViaUi:'请通过 khat ui 打开此页面',errTicket:'票据无效或已过期',err401:'401 未授权(key 无效或被撤销)',err402:'402 余额不足',err429:'429 限速',errHttp:'HTTP {h} 错误',errOccurredAt:'发生于',trafficTip:'{t}:成功 {ok} 次 · 失败 {bad} 次',showLogs:'显示 {n} / {t} 条',statKeysSubTpl:'可用 {a} 个 · 共 {t} 个',statReqSubTpl:'成功 {ok} 次 · 失败 {fail} 次',keyCountTpl:'{n} 个 key',trafficAria:'请求成功 / 失败趋势'},
|
|
145
|
+
en:{title:'khat Key Manager',toggleTheme:'Toggle dark / light theme',toggleLang:'Switch language',keyFilterAria:'Filter keys',statusFilterAria:'Filter by status',resultFilterAria:'Filter by result',logCountAria:'Log count',autoRefresh:'Auto-refresh every 30s',refresh:'Refresh',statProviders:'Providers',statProvidersSub:'Configured services',statKeys:'Key availability',statReq:'Recent success rate',statReqSub:'From last fetched logs',statTokens:'Recent tokens',statTokensSub:'Input / Output total',cardProviders:'Provider / Key Management',cardProvidersSub:'Manage providers, key pools and weights; filtering is local only.',phProviderId:'provider id',phName:'Name',phBaseUrl:'https://api.example.com',btnAddProvider:'Add Provider',phKeyId:'key id',weightTitle:'Polling weight: relative share among keys in this provider, default 1',phKeyValue:'API key plaintext (stored encrypted)',btnAddKey:'Add Key',phSearchKey:'Search provider / baseUrl / key id / error…',allStatus:'All status',onlyAvail:'Available only',onlyUnavail:'Unavailable only',cardRoutes:'Routes',cardRoutesSub:'Model-to-Provider forwarding rules.',phModel:'model',btnAddRoute:'Batch add Routes',btnLoadModels:'Load models',thModel:'Model',thProvider:'Provider',cardTraffic:'Request success / failure trend',cardTrafficSub:'Aggregates recent logs into time buckets (max 30). Hover bars for detail.',legendOk:'Success',legendFail:'Failure',chartEmpty:'No request records',cardLogs:'Logs',cardLogsSub:'Recent forwarding logs; keyword & result filtering is local; changing count re-fetches.',phFilterLog:'Filter by model / key / provider / status…',allResult:'All results',onlyOk:'Success only',onlyFail:'Failure only',logTail100:'Last 100',logTail300:'Last 300',logTail1000:'Last 1000',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'Error reason',thActions:'Actions',avail:'Available',unavail:'Unavailable',disabled:'Disabled',noKeysMatch:'No keys match the filters',noKeysYet:'No keys yet for this provider; add via the form above',keyMatchInfo:'{k} / {t} keys matched · {p} providers shown',noProviderMatch:'No matching provider or key; adjust filters.',noRoutes:'No routes configured',noLogs:'No request logs',noLogMatch:'No matching logs; adjust filters.',poolWarn:'Provider {p} has no available keys. Recover or probe a key before retrying.',btnDisable:'Disable',btnRecover:'Enable',btnProbe:'Probe',btnDelete:'Delete',confirmDelKey:'Delete key {p}/{k}?',saved:'Saved',errOpenViaUi:'Please open this page via khat ui',errTicket:'Ticket invalid or expired',err401:'401 Unauthorized (key invalid or revoked)',err402:'402 Insufficient balance',err429:'429 Rate limited',errHttp:'HTTP {h} error',errOccurredAt:'at',trafficTip:'{t}: {ok} ok · {bad} failed',showLogs:'Showing {n} / {t}',statKeysSubTpl:'{a} available · {t} total',statReqSubTpl:'{ok} ok · {fail} failed',keyCountTpl:'{n} keys',trafficAria:'request success/failure trend'}
|
|
146
|
+
};
|
|
147
|
+
let lang=(()=>{try{return localStorage.getItem('khatLang')==='en'?'en':'zh'}catch(e){return 'zh'}})();
|
|
148
|
+
function t(key){return (I18N[lang]&&I18N[lang][key])||I18N.zh[key]||key}
|
|
149
|
+
function tf(key,vars){let s=t(key);if(vars)for(const k in vars)s=s.split('{'+k+'}').join(String(vars[k]));return s}
|
|
150
|
+
function applyLang(l){lang=l;try{localStorage.setItem('khatLang',l)}catch(e){}document.documentElement.lang=l==='en'?'en':'zh-CN';document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n)});document.querySelectorAll('[data-i18n-ph]').forEach(el=>{el.placeholder=t(el.dataset.i18nPh)});document.querySelectorAll('[data-i18n-title]').forEach(el=>{el.title=t(el.dataset.i18nTitle)});document.querySelectorAll('[data-i18n-aria]').forEach(el=>{el.setAttribute('aria-label',t(el.dataset.i18nAria))});renderConnection();renderStats();renderProviders();renderRoutes();renderLogs();drawTraffic();const b=$('#langToggle');if(b)b.textContent=l==='zh'?'EN':'中'}
|
|
151
|
+
function toast(msg,type,ms){type=type||'error';ms=ms||3500;const c=$('#toasts');if(!c)return;const el=document.createElement('div');el.className='toast toast-'+type;el.textContent=msg;c.append(el);setTimeout(()=>{el.classList.add('fade');setTimeout(()=>el.remove(),300)},ms)}
|
|
152
|
+
async function api(path,init){init=init||{};const r=await fetch(path,{...init,headers:{authorization:'Bearer '+sessionStorage.khatSession,'content-type':'application/json',...(init.headers||{})}});const body=await r.json().catch(()=>({}));if(!r.ok)throw Error(body.error?.message||r.statusText);return body}
|
|
153
|
+
async function exchange(){const ticket=new URLSearchParams(location.search).get('ticket');if(sessionStorage.khatSession){if(ticket)window.history.replaceState({},'', '/_keys/ui');return}if(ticket){const r=await fetch('/_keys/ticket/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ticket})});if(!r.ok)throw Error(t('errTicket'));sessionStorage.khatSession=(await r.json()).session;window.history.replaceState({},'', '/_keys/ui')}if(!sessionStorage.khatSession)throw Error(t('errOpenViaUi'))}
|
|
154
154
|
function cell(v,cls){const e=document.createElement('td');if(cls)e.className=cls;e.textContent=v==null?'—':String(v);return e}
|
|
155
155
|
function fmtNum(n){return n>=100000?Math.round(n/1000)+'k':n>=10000?(n/1000).toFixed(1)+'k':String(n)}
|
|
156
156
|
function fmtHM(m){const d=new Date(m*60000);return pad2(d.getHours())+':'+pad2(d.getMinutes())}
|
|
157
157
|
function formatTs(ts){const d=new Date(ts);if(isNaN(d.getTime()))return String(ts);const day=d.toDateString()===new Date().toDateString()?'':(d.getMonth()+1)+'-'+d.getDate()+' ';return day+pad2(d.getHours())+':'+pad2(d.getMinutes())+':'+pad2(d.getSeconds())}
|
|
158
|
-
function errText(k){const http=k.lastError&&k.lastError.http;if(http===undefined)return '—';return (
|
|
158
|
+
function errText(k){const http=k.lastError&&k.lastError.http;if(http===undefined)return '—';if(http===401)return t('err401');if(http===402)return t('err402');if(http===429)return t('err429');return tf('errHttp',{h:http})}
|
|
159
159
|
function btn(label,className,onclick){const b=document.createElement('button');b.type='button';b.className=className;b.textContent=label;b.onclick=onclick;return b}
|
|
160
|
-
function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message}}}
|
|
160
|
+
function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message;toast(e.message)}}}
|
|
161
161
|
function statusCell(ok,text,title){const td=document.createElement('td');const sp=document.createElement('span');sp.className='pill pill-status '+(ok?'pill-ok':'pill-bad');sp.textContent=text;if(title)sp.title=title;td.append(sp);return td}
|
|
162
162
|
function syncProviderSelect(sel,providers){const ids=providers.map(p=>p.id).join(',');if(sel.dataset.ids===ids)return;sel.dataset.ids=ids;const prev=sel.value;sel.replaceChildren(...providers.map(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.id;return o}));if(ids.split(',').includes(prev))sel.value=prev}
|
|
163
|
-
function
|
|
164
|
-
function
|
|
165
|
-
function
|
|
166
|
-
function providerHead(p,keys){const head=document.createElement('div');head.className='provider-head';const nm=document.createElement('h3');nm.className='provider-name';nm.textContent=p.name||p.id;nm.title=p.id;head.append(nm);const pp=document.createElement('span');pp.className='pill protocol-pill';pp.textContent=p.protocol;head.append(pp);const url=document.createElement('span');url.className='provider-url';url.textContent=p.baseUrl||'';url.title=p.baseUrl||'';head.append(url);const cnt=document.createElement('span');cnt.className='provider-count';cnt.textContent=keys.length+(keys.length!==p.keys.length?' / '+p.keys.length:'')+' 个 key';head.append(cnt);return head}
|
|
163
|
+
function renderConnection(){if(!state.status)return;const c=$('#connection');c.replaceChildren(Object.assign(document.createElement('i'),{className:'dot'}),document.createTextNode(' '+state.status.listen))}
|
|
164
|
+
function renderStats(){const st=state.status;if(!st)return;const keys=[];for(const p of st.providers)for(const k of p.keys)keys.push(k);const avail=keys.filter(k=>k.enabled!==false&&k.status==='available').length;$('#statProviders').textContent=String(st.providers.length);$('#statKeys').textContent=keys.length?Math.round(avail*100/keys.length)+'%':'—';$('#statKeysSub').textContent=keys.length?tf('statKeysSubTpl',{a:avail,t:keys.length}):'—';let ok=0,fail=0,tIn=0,tOut=0;for(const x of state.logs){if(Number(x.status)<400)ok++;else fail++;tIn+=x.tokensIn||0;tOut+=x.tokensOut||0}const total=ok+fail;$('#statReq').textContent=total?Math.round(ok*100/total)+'%':'—';$('#statReqSub').textContent=tf('statReqSubTpl',{ok:ok,fail:fail});$('#statTokens').textContent=total?fmtNum(tIn)+' / '+fmtNum(tOut):'—'}
|
|
165
|
+
function providerHead(p,keys){const head=document.createElement('div');head.className='provider-head';const nm=document.createElement('h3');nm.className='provider-name';nm.textContent=p.name||p.id;nm.title=p.id;head.append(nm);const pp=document.createElement('span');pp.className='pill protocol-pill';pp.textContent=p.protocol;head.append(pp);const url=document.createElement('span');url.className='provider-url';url.textContent=p.baseUrl||'';url.title=p.baseUrl||'';head.append(url);const cnt=document.createElement('span');cnt.className='provider-count';cnt.textContent=tf('keyCountTpl',{n:keys.length+(keys.length!==p.keys.length?(' / '+p.keys.length):'')});head.append(cnt);return head}
|
|
167
166
|
function renderProviders(){
|
|
168
167
|
const box=$('#providers');box.replaceChildren();
|
|
169
168
|
const st=state.status;if(!st)return;
|
|
@@ -177,11 +176,13 @@ function renderProviders(){
|
|
|
177
176
|
if(p.keys.length&&!keys.length)continue;
|
|
178
177
|
if(!p.keys.length&&!hitMeta)continue;
|
|
179
178
|
shownP++;shownK+=keys.length;
|
|
179
|
+
const hasAvailableKey=p.keys.some(k=>k.enabled!==false&&k.status==='available');
|
|
180
180
|
const blk=document.createElement('div');blk.className='provider-block';
|
|
181
181
|
blk.append(providerHead(p,keys));
|
|
182
|
+
if(p.keys.length&&!hasAvailableKey){const warn=document.createElement('p');warn.className='alert alert-warn';warn.textContent=tf('poolWarn',{p:p.name||p.id});blk.append(warn)}
|
|
182
183
|
const tw=document.createElement('div');tw.className='table-wrap';
|
|
183
184
|
const table=document.createElement('table');
|
|
184
|
-
table.innerHTML='<thead><tr><th>
|
|
185
|
+
table.innerHTML='<thead><tr><th>'+t('thKey')+'</th><th>'+t('thWeight')+'</th><th>'+t('thStatus')+'</th><th class="num">'+t('thRequests')+'</th><th class="num">'+t('thFailed')+'</th><th class="num">'+t('thTokensInOut')+'</th><th>'+t('thErrReason')+'</th><th>'+t('thActions')+'</th></tr></thead>';
|
|
185
186
|
const tb=document.createElement('tbody');
|
|
186
187
|
for(const k of keys){
|
|
187
188
|
const tr=document.createElement('tr');
|
|
@@ -190,40 +191,39 @@ function renderProviders(){
|
|
|
190
191
|
if(k.secret){const sk=document.createElement('span');sk.className='key-secret';sk.textContent=k.secret;kt.append(sk)}
|
|
191
192
|
tr.append(kt);
|
|
192
193
|
tr.append(cell(k.weight,'num'));
|
|
193
|
-
tr.append(statusCell(k.status==='available',k.status==='available'?'
|
|
194
|
+
tr.append(statusCell(k.enabled!==false&&k.status==='available',k.enabled===false?t('disabled'):(k.status==='available'?t('avail'):t('unavail')),k.lastError?errText(k)+(k.lastError.at?' · '+t('errOccurredAt')+' '+k.lastError.at:''):''));
|
|
194
195
|
tr.append(cell(k.counters.requests||0,'num'),cell(k.counters.failed||0,'num'),cell((k.counters.tokensIn||0)+' / '+(k.counters.tokensOut||0),'num'));
|
|
195
|
-
const et=document.createElement('td');et.textContent=errText(k);et.className=k.lastError?'err-text':'err-text none';if(k.lastError)et.title='
|
|
196
|
+
const et=document.createElement('td');et.textContent=errText(k);et.className=k.lastError?'err-text':'err-text none';if(k.lastError)et.title=t('errOccurredAt')+' '+k.lastError.at;
|
|
196
197
|
tr.append(et);
|
|
197
198
|
const ac=document.createElement('td');
|
|
198
199
|
const rowA=document.createElement('div');rowA.className='row-actions';
|
|
199
|
-
const wInput=document.createElement('input');wInput.type='number';wInput.min='1';wInput.step='1';wInput.value=k.weight;wInput.title='
|
|
200
|
-
wInput.onchange=run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'PUT',body:JSON.stringify({weight:Number(wInput.value)})});load()});
|
|
200
|
+
const wInput=document.createElement('input');wInput.type='number';wInput.min='1';wInput.step='1';wInput.value=k.weight;wInput.title=t('weightTitle');
|
|
201
|
+
wInput.onchange=run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'PUT',body:JSON.stringify({weight:Number(wInput.value)})});toast(t('saved'),'ok');load()});
|
|
201
202
|
rowA.append(wInput);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
rowA.append(btn('编辑','btn btn-secondary btn-sm',()=>openKeyEdit(p.id,k.id,k.weight)));
|
|
208
|
-
rowA.append(btn('删除','btn btn-danger btn-sm',run(async()=>{if(confirm('删除 key '+p.id+'/'+k.id+'?')){await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'DELETE'});load()}})));
|
|
203
|
+
rowA.append(btn(k.enabled!==false?t('btnDisable'):t('btnRecover'),'btn btn-secondary btn-sm',run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id)+'/'+(k.enabled!==false?'disable':'enable'),{method:'POST'});toast(t('saved'),'ok');load()})));
|
|
204
|
+
rowA.append(btn(t('btnProbe'),'btn btn-secondary btn-sm',run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id)+'/probe',{method:'POST'});toast(t('saved'),'ok');load()})));
|
|
205
|
+
|
|
206
|
+
rowA.append(btn(t('btnDelete'),'btn btn-danger btn-sm',run(async()=>{if(confirm(tf('confirmDelKey',{p:p.id,k:k.id}))){await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'DELETE'});toast(t('saved'),'ok');load()}})));
|
|
209
207
|
ac.append(rowA);tr.append(ac);
|
|
210
208
|
tb.append(tr);
|
|
211
209
|
}
|
|
212
|
-
if(!keys.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=8;ed.textContent=p.keys.length?'
|
|
210
|
+
if(!keys.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=8;ed.textContent=p.keys.length?t('noKeysMatch'):t('noKeysYet');er.append(ed);tb.append(er)}
|
|
213
211
|
table.append(tb);tw.append(table);blk.append(tw);box.append(blk);
|
|
214
212
|
}
|
|
215
|
-
$('#keyMatchInfo').textContent='
|
|
216
|
-
if(shownP===0){const em=document.createElement('div');em.className='empty-state';em.textContent='
|
|
213
|
+
$('#keyMatchInfo').textContent=tf('keyMatchInfo',{k:shownK,t:totalK,p:shownP});
|
|
214
|
+
if(shownP===0){const em=document.createElement('div');em.className='empty-state';em.textContent=t('noProviderMatch');box.append(em)}
|
|
217
215
|
}
|
|
218
216
|
function renderRoutes(){
|
|
219
217
|
const tb=$('#routes');tb.replaceChildren();
|
|
220
218
|
const rs=state.status?state.status.routes:[];
|
|
221
|
-
if(!rs.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=
|
|
219
|
+
if(!rs.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=4;ed.textContent=t('noRoutes');er.append(ed);tb.append(er);return}
|
|
222
220
|
for(const rt of rs){
|
|
223
221
|
const tr=document.createElement('tr');
|
|
224
|
-
tr.append(cell(rt.model,'mono-cell'),cell(rt.provider));
|
|
222
|
+
tr.append(cell(rt.model,'mono-cell'),cell(rt.provider),statusCell(rt.enabled!==false,rt.enabled!==false?t('avail'):t('unavail')));
|
|
225
223
|
const d=document.createElement('td');
|
|
226
|
-
|
|
224
|
+
const toggle=rt.enabled!==false?'disable':'enable';
|
|
225
|
+
d.append(btn(rt.enabled!==false?t('btnDisable'):t('btnRecover'),'btn btn-secondary btn-sm',run(async()=>{await api('/_keys/routes/'+toggle+'?model='+encodeURIComponent(rt.model),{method:'POST'});toast(t('saved'),'ok');load()})));
|
|
226
|
+
d.append(btn(t('btnDelete'),'btn btn-danger btn-sm',run(async()=>{await api('/_keys/routes?model='+encodeURIComponent(rt.model),{method:'DELETE'});toast(t('saved'),'ok');load()})));
|
|
227
227
|
tr.append(d);tb.append(tr);
|
|
228
228
|
}
|
|
229
229
|
}
|
|
@@ -236,8 +236,8 @@ function renderLogs(){
|
|
|
236
236
|
if(!kw)return true;
|
|
237
237
|
return [x.model,x.key,x.provider,x.status].some(v=>String(v).toLowerCase().includes(kw));
|
|
238
238
|
});
|
|
239
|
-
$('#logMatchInfo').textContent='
|
|
240
|
-
if(!rows.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=7;ed.textContent=state.logs.length?'
|
|
239
|
+
$('#logMatchInfo').textContent=tf('showLogs',{n:rows.length,t:state.logs.length});
|
|
240
|
+
if(!rows.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=7;ed.textContent=state.logs.length?t('noLogMatch'):t('noLogs');er.append(ed);tb.append(er);return}
|
|
241
241
|
for(const e of rows){
|
|
242
242
|
const r=document.createElement('tr');
|
|
243
243
|
r.append(cell(formatTs(e.ts),'mono-cell'));
|
|
@@ -253,7 +253,7 @@ function drawTraffic(){
|
|
|
253
253
|
const svg=$('#traffic');svg.replaceChildren();
|
|
254
254
|
const emptyBox=$('#chartEmpty');
|
|
255
255
|
const times=[];
|
|
256
|
-
for(const x of state.logs){const
|
|
256
|
+
for(const x of state.logs){const t0=new Date(x.ts).getTime();if(Number.isFinite(t0))times.push({m:Math.floor(t0/60000),ok:Number(x.status)<400})}
|
|
257
257
|
if(!times.length){emptyBox.hidden=false;return}
|
|
258
258
|
emptyBox.hidden=true;
|
|
259
259
|
let minM=Infinity,maxM=-Infinity;
|
|
@@ -272,12 +272,22 @@ function drawTraffic(){
|
|
|
272
272
|
if(!b)continue;
|
|
273
273
|
const total=b.ok+b.bad,h=total/peak*(axisY-topPad),badH=b.bad/total*h,rx=Math.min(3,bw/3);
|
|
274
274
|
const g=document.createElementNS(NS,'g');
|
|
275
|
-
const ti=document.createElementNS(NS,'title');ti.textContent=fmtHM(startM)
|
|
275
|
+
const ti=document.createElementNS(NS,'title');ti.textContent=tf('trafficTip',{t:fmtHM(startM),ok:b.ok,bad:b.bad});g.append(ti);
|
|
276
276
|
if(b.ok)g.append(mk('rect',{x:(cx-bw/2).toFixed(1),y:(axisY-h+badH).toFixed(1),width:bw.toFixed(1),height:(h-badH).toFixed(1),rx:rx,fill:'#10b981'}));
|
|
277
277
|
if(b.bad)g.append(mk('rect',{x:(cx-bw/2).toFixed(1),y:(axisY-h).toFixed(1),width:bw.toFixed(1),height:badH.toFixed(1),rx:rx,fill:'#ef4444'}));
|
|
278
278
|
svg.append(g);
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
|
+
async function loadModels(){
|
|
282
|
+
const provider=$('#routeForm [name=provider]').value;
|
|
283
|
+
const select=$('#routeForm [name=models]');
|
|
284
|
+
select.replaceChildren();
|
|
285
|
+
if(!provider)return;
|
|
286
|
+
try{
|
|
287
|
+
const r=await api('/_keys/providers/'+encodeURIComponent(provider)+'/models');
|
|
288
|
+
for(const model of r.models||[]){const o=document.createElement('option');o.value=model;o.textContent=model;select.append(o)}
|
|
289
|
+
}catch(e){error.textContent=e.message;toast(e.message)}
|
|
290
|
+
}
|
|
281
291
|
async function load(){
|
|
282
292
|
try{
|
|
283
293
|
await exchange();
|
|
@@ -292,16 +302,17 @@ async function load(){
|
|
|
292
302
|
renderRoutes();
|
|
293
303
|
renderLogs();
|
|
294
304
|
drawTraffic();
|
|
295
|
-
}catch(e){error.textContent=e.message}
|
|
305
|
+
}catch(e){error.textContent=e.message;toast(e.message)}
|
|
296
306
|
}
|
|
297
|
-
function applyTheme(
|
|
307
|
+
function applyTheme(theme){document.documentElement.setAttribute('data-theme',theme);try{localStorage.setItem('khatTheme',theme)}catch(e){}const b=$('#themeToggle');b.textContent=theme==='dark'?'☀️':'🌙';b.setAttribute('aria-label',t('toggleTheme'))}
|
|
298
308
|
$('#themeToggle').onclick=()=>applyTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark');
|
|
309
|
+
$('#langToggle').onclick=()=>applyLang(lang==='zh'?'en':'zh');
|
|
299
310
|
applyTheme(document.documentElement.getAttribute('data-theme')||'light');
|
|
300
|
-
|
|
301
|
-
$('#
|
|
302
|
-
$('#
|
|
303
|
-
$('#
|
|
304
|
-
$('#
|
|
311
|
+
applyLang(lang);
|
|
312
|
+
$('#routeModels').onclick=()=>loadModels();
|
|
313
|
+
$('#providerForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers',{method:'POST',body:JSON.stringify(x)});e.target.reset();toast(t('saved'),'ok');load()});
|
|
314
|
+
$('#keyForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers/'+encodeURIComponent(x.provider)+'/keys',{method:'POST',body:JSON.stringify({id:x.id,value:x.value,weight:Number(x.weight)||1})});e.target.reset();toast(t('saved'),'ok');load()});
|
|
315
|
+
$('#routeForm').onsubmit=run(async e=>{e.preventDefault();const provider=e.target.provider.value;const models=[...e.target.models.selectedOptions].map(o=>o.value);for(const model of models)await api('/_keys/routes',{method:'POST',body:JSON.stringify({model,provider})});e.target.reset();toast(t('saved'),'ok');load()});
|
|
305
316
|
$('#keySearch').oninput=e=>{state.keyKeyword=e.target.value;renderProviders()};
|
|
306
317
|
$('#keyStatus').onchange=e=>{state.keyStatus=e.target.value;renderProviders()};
|
|
307
318
|
$('#logFilter').oninput=e=>{state.logKeyword=e.target.value;renderLogs()};
|