@alilis/k-hat 0.2.0 → 0.2.1

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 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 === 'anthropic' ? { 'content-type': 'application/json', 'x-api-key': secret, 'anthropic-version': '2023-06-01' } : { 'content-type': 'application/json', authorization: `Bearer ${secret}` };
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 (!body.provider || typeof body.provider !== 'string')
171
- throw new Error('provider is required');
172
- await store.mutate(() => updateRoute(store, decodeURIComponent(sub[1]), body.provider));
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,7 +27,8 @@ Usage: khat <command> [arguments]
27
27
 
28
28
  Setup
29
29
  init first-time setup: create vault + access token + default config
30
- start [--foreground] start the proxy as a background daemon (or foreground with --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
@@ -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 {
@@ -244,10 +257,16 @@ async function runForeground() {
244
257
  process.once('SIGINT', shutdown);
245
258
  process.once('SIGTERM', shutdown);
246
259
  }
247
- async function runStart(foreground) {
260
+ async function runStart(foreground, port) {
261
+ const store = await openInitializedStore();
262
+ if (port !== undefined && port !== store.config.port) {
263
+ if (await daemonRunning())
264
+ throw new Error(`khat is already running on port ${store.config.port}; run 'khat stop' first, then start with --port ${port}`);
265
+ store.config.port = port;
266
+ await store.saveConfig();
267
+ }
248
268
  if (foreground)
249
269
  return runForeground();
250
- const store = await openInitializedStore();
251
270
  if (await daemonRunning())
252
271
  throw new Error('khat is already running; use khat stop first');
253
272
  const occupiedPid = await findListeningPid(store.config.port);
@@ -412,18 +431,23 @@ async function runImport(path, force) {
412
431
  await importPortable(path, dataDir, createKeyProtector(), password, force);
413
432
  console.log(`imported encrypted vault into ${dataDir}`);
414
433
  }
415
- async function runInit() {
434
+ async function runVersion() {
435
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
436
+ console.log(pkg.version);
437
+ }
438
+ async function runInit(port) {
416
439
  await mkdir(dataDir, { recursive: true, mode: 0o700 });
417
440
  if (await fileExists(configPath)) {
418
441
  console.error(`already initialized at ${configPath} (delete the directory to start over)`);
419
442
  process.exitCode = 1;
420
443
  return;
421
444
  }
445
+ const config = port === undefined || port === defaultConfig.port ? defaultConfig : { ...defaultConfig, port };
422
446
  const vault = await Vault.create(vaultPath, createKeyProtector());
423
447
  const token = generateAccessToken();
424
448
  vault.set(ACCESS_TOKEN_REF, token);
425
449
  await vault.save();
426
- await saveJsonAtomic(configPath, defaultConfig);
450
+ await saveJsonAtomic(configPath, config);
427
451
  await saveJsonAtomic(statePath, { keys: {} });
428
452
  console.log(`Initialized ${dataDir}`);
429
453
  console.log();
@@ -431,7 +455,7 @@ async function runInit() {
431
455
  console.log(` ${token}`);
432
456
  console.log();
433
457
  console.log('Point your tools at the proxy, e.g.:');
434
- console.log(` base URL: http://${defaultConfig.bind}:${defaultConfig.port}/v1`);
458
+ console.log(` base URL: http://${config.bind}:${config.port}/v1`);
435
459
  console.log(` Authorization: Bearer ${token}`);
436
460
  }
437
461
  const [command, ...rest] = process.argv.slice(2);
@@ -443,8 +467,13 @@ try {
443
467
  case '-h':
444
468
  console.log(USAGE);
445
469
  break;
470
+ case 'version':
471
+ case '--version':
472
+ case '-v':
473
+ await runVersion();
474
+ break;
446
475
  case 'init':
447
- await runInit();
476
+ await runInit(flagPort(flags, 'port'));
448
477
  break;
449
478
  case 'export':
450
479
  await runExport(positionals[0]);
@@ -453,7 +482,7 @@ try {
453
482
  await runImport(positionals[0], flags.force === true);
454
483
  break;
455
484
  case 'start':
456
- await runStart(flags.foreground === true);
485
+ await runStart(flags.foreground === true, flagPort(flags, 'port'));
457
486
  break;
458
487
  case 'stop':
459
488
  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 resolveProvider(config, model) {
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 provider = config.providers.find((item) => item.id === providerId);
6
- return provider && config.routes.some((route) => route.provider === providerId && route.model === model.slice(slash + 1)) ? provider : undefined;
5
+ const routeModel = model.slice(slash + 1);
6
+ return config.routes.find((route) => route.provider === providerId && route.model === routeModel);
7
7
  }
8
- const route = config.routes.find((item) => item.model === model);
9
- return route ? config.providers.find((item) => item.id === route.provider) : undefined;
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
- void logger?.append({ ts: new Date().toISOString(), event: 'forward', model: parsed.model, provider: provider.id, key: keyRef, status: upstream.status, ttfbMs, durationMs, bytes, tokensIn, tokensOut });
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);
@@ -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="themeToggle" class="btn btn-secondary btn-icon" type="button" title="切换深色 / 浅色模式">🌙</button><span class="auto-note">每 30 秒自动刷新</span><button id="refresh" class="btn btn-secondary" type="button">刷新</button></div>
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="轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比,默认 1"><input name="value" type="password" placeholder="API key 明文(加密存入密钥库)" required autocomplete="new-password"><button class="btn btn-primary">添加 Key</button></form>
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"><input name="model" placeholder="model" required><select name="provider" required></select><button class="btn btn-primary">添加 Route</button></form>
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
- async function api(path,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('票据无效或已过期');sessionStorage.khatSession=(await r.json()).session;window.history.replaceState({},'', '/_keys/ui')}if(!sessionStorage.khatSession)throw Error('请通过 khat ui 打开此页面')}
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 ({401:'401 未授权(key 无效或被撤销)',402:'402 余额不足',429:'429 限速'})[http]||('HTTP '+http+' 错误')}
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 openKeyEdit(providerId,keyId,weight){const d=$('#keyEdit');d.querySelector('[name=provider]').value=providerId;d.querySelector('[name=id]').value=keyId;d.querySelector('[name=weight]').value=weight;d.querySelector('[name=value]').value='';d.showModal();d.querySelector('[name=weight]').focus()}
164
- function renderConnection(){const c=$('#connection');c.replaceChildren(Object.assign(document.createElement('i'),{className:'dot'}),document.createTextNode(' '+state.status.listen))}
165
- 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.status==='available').length;$('#statProviders').textContent=String(st.providers.length);$('#statKeys').textContent=keys.length?Math.round(avail*100/keys.length)+'%':'—';$('#statKeysSub').textContent='可用 '+avail+' 个 · 共 '+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='成功 '+ok+' 次 · 失败 '+fail+' 次';$('#statTokens').textContent=total?fmtNum(tIn)+' / '+fmtNum(tOut):''}
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>Key</th><th>Weight</th><th>Status</th><th class="num">Requests</th><th class="num">Failed</th><th class="num">Tokens in/out</th><th>错误原因</th><th>操作</th></tr></thead>';
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'?'可用':'不可用',k.lastError?errText(k)+(k.lastError.at?' · 发生于 '+k.lastError.at:''):''));
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='发生时间 '+k.lastError.at;
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
- for(const pair of [['恢复','enable'],['探活','probe']]){
203
- const b=btn(pair[0],'btn btn-secondary btn-sm',run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id)+'/'+pair[1],{method:'POST'});load()}));
204
- b.disabled=pair[0]==='恢复'&&k.status==='available';
205
- rowA.append(b);
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?'没有符合筛选条件的 key':'该 Provider 尚未添加 key,可在上方表单中添加';er.append(ed);tb.append(er)}
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='匹配 '+shownK+' / '+totalK+' 个 key · 显示 '+shownP+' 个 Provider';
216
- if(shownP===0){const em=document.createElement('div');em.className='empty-state';em.textContent='没有匹配的 Provider 或 Key,试试调整关键字或状态筛选。';box.append(em)}
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=3;ed.textContent='暂无路由配置';er.append(ed);tb.append(er);return}
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
- d.append(btn('删除','btn btn-danger btn-sm',run(async()=>{await api('/_keys/routes?model='+encodeURIComponent(rt.model),{method:'DELETE'});load()})));
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='显示 '+rows.length+' / '+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?'没有匹配的日志,试试调整筛选条件。':'暂无请求日志';er.append(ed);tb.append(er);return}
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 t=new Date(x.ts).getTime();if(Number.isFinite(t))times.push({m:Math.floor(t/60000),ok:Number(x.status)<400})}
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)+':成功 '+b.ok+' 次 · 失败 '+b.bad+' 次';g.append(ti);
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(t){document.documentElement.setAttribute('data-theme',t);try{localStorage.setItem('khatTheme',t)}catch(e){}const b=$('#themeToggle');b.textContent=t==='dark'?'☀️':'🌙';b.setAttribute('aria-label',t==='dark'?'切换浅色模式':'切换深色模式')}
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
- $('#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();load()});
301
- $('#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();load()});
302
- $('#routeForm').onsubmit=run(async e=>{e.preventDefault();await api('/_keys/routes',{method:'POST',body:JSON.stringify(Object.fromEntries(new FormData(e.target)))});e.target.reset();load()});
303
- $('#keyEditForm').onsubmit=run(async e=>{e.preventDefault();const d=$('#keyEdit'),provider=d.querySelector('[name=provider]').value,id=d.querySelector('[name=id]').value,body={weight:Number(d.querySelector('[name=weight]').value)||1},v=d.querySelector('[name=value]').value.trim();if(v)body.value=v;await api('/_keys/providers/'+encodeURIComponent(provider)+'/keys/'+encodeURIComponent(id),{method:'PUT',body:JSON.stringify(body)});d.close();load()});
304
- $('#keyEditCancel').onclick=()=>$('#keyEdit').close();
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()};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alilis/k-hat",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },