@alilis/k-hat 0.2.4 → 0.2.6

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/dist/admin.js CHANGED
@@ -4,6 +4,8 @@ import { join } from 'node:path';
4
4
  import { UiSessions, serveUi } from './web-ui.js';
5
5
  import { ACCESS_TOKEN_REF, generateAccessToken } from './vault.js';
6
6
  import { providerUrl } from './config.js';
7
+ import { findRoute, routeUpstreamModel } from './router.js';
8
+ import { readExport, applyImport } from './portable-vault.js';
7
9
  // Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
8
10
  // only from loopback and behind the proxy access token, so a local non-loopback
9
11
  // client or a process without the token cannot mutate config/state/vault.
@@ -76,7 +78,12 @@ function buildStatus(store) {
76
78
  };
77
79
  })
78
80
  })),
79
- routes: store.config.routes.map((route) => route.enabled === false ? { ...route, enabled: false } : { model: route.model, provider: route.provider })
81
+ routes: store.config.routes.map((route) => ({
82
+ model: route.model,
83
+ provider: route.provider,
84
+ ...(route.upstreamModel ? { upstreamModel: route.upstreamModel } : {}),
85
+ ...(route.enabled === false ? { enabled: false } : {})
86
+ }))
80
87
  };
81
88
  }
82
89
  async function route(req, res, store, sessionAuthorized = false) {
@@ -112,6 +119,18 @@ async function route(req, res, store, sessionAuthorized = false) {
112
119
  }
113
120
  if (sub[0] === 'status' && method === 'GET')
114
121
  return json(res, 200, buildStatus(store));
122
+ if (sub[0] === 'import' && sub.length === 1 && method === 'POST') {
123
+ const body = await readJsonBody(req);
124
+ if (typeof body.content !== 'string' || !body.content)
125
+ throw new Error('content (the export file text) is required');
126
+ if (typeof body.password !== 'string' || !body.password)
127
+ throw new Error('password is required');
128
+ if (body.content.length > 24 * 1024 * 1024)
129
+ throw new Error('export file is too large');
130
+ const payload = readExport(body.content, body.password);
131
+ const result = await store.mutate(() => applyImport(store, payload));
132
+ return json(res, 200, { ok: true, ...result });
133
+ }
115
134
  if (sub[0] === 'logs' && method === 'GET') {
116
135
  const tail = Number.parseInt(url.searchParams.get('tail') ?? '50', 10);
117
136
  const logs = await new LogWriter(join(store.dir, 'logs')).recent(Number.isFinite(tail) ? tail : 50);
@@ -181,13 +200,18 @@ async function route(req, res, store, sessionAuthorized = false) {
181
200
  if (!provider || !key)
182
201
  throw new Error('unknown provider or key');
183
202
  const secret = store.vault.get(key.vaultRef);
184
- const model = store.config.routes.find((item) => item.provider === provider.id)?.model;
185
- if (!secret || !model)
203
+ const requestedModel = url.searchParams.get('model');
204
+ const route = requestedModel
205
+ ? findRoute(store.config, requestedModel)
206
+ : store.config.routes.find((item) => item.provider === provider.id);
207
+ if (!secret || !route || route.provider !== provider.id)
186
208
  throw new Error('a key and route model are required for probing');
209
+ const model = routeUpstreamModel(route);
187
210
  const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
188
211
  const headers = upstreamHeaders(provider.protocol, secret, true);
189
212
  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 };
190
213
  const response = await fetch(providerUrl(provider.baseUrl, path), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
214
+ await response.arrayBuffer();
191
215
  if (response.ok)
192
216
  await store.mutate(() => enableKey(store, provider.id, key.id));
193
217
  return json(res, response.ok ? 200 : 502, { ok: response.ok, status: response.status });
@@ -198,15 +222,24 @@ async function route(req, res, store, sessionAuthorized = false) {
198
222
  const body = await readJsonBody(req);
199
223
  if (!body.model || !body.provider)
200
224
  throw new Error('model and provider are required');
201
- await store.mutate(() => addRoute(store, body.model, body.provider));
225
+ if (body.upstreamModel !== undefined && (typeof body.upstreamModel !== 'string' || !body.upstreamModel.trim()))
226
+ throw new Error('upstreamModel must be a non-empty string');
227
+ await store.mutate(() => addRoute(store, body.model, body.provider, body.upstreamModel));
202
228
  return json(res, 200, { ok: true });
203
229
  }
204
230
  if (sub.length === 2 && method === 'PUT') {
205
231
  const body = await readJsonBody(req);
206
- if (body.provider !== undefined)
207
- await store.mutate(() => updateRoute(store, decodeURIComponent(sub[1]), body.provider));
232
+ if (body.provider !== undefined && typeof body.provider !== 'string')
233
+ throw new Error('provider must be a string');
234
+ if (body.upstreamModel !== undefined && body.upstreamModel !== null && (typeof body.upstreamModel !== 'string' || !body.upstreamModel.trim()))
235
+ throw new Error('upstreamModel must be a non-empty string or null');
236
+ if (body.enabled !== undefined && typeof body.enabled !== 'boolean')
237
+ throw new Error('enabled must be a boolean');
238
+ const model = decodeURIComponent(sub[1]);
239
+ if (body.provider !== undefined || body.upstreamModel !== undefined)
240
+ await store.mutate(() => updateRoute(store, model, { provider: body.provider, upstreamModel: body.upstreamModel }));
208
241
  if (body.enabled !== undefined)
209
- await store.mutate(() => setRouteEnabled(store, decodeURIComponent(sub[1]), body.enabled));
242
+ await store.mutate(() => setRouteEnabled(store, model, body.enabled));
210
243
  return json(res, 200, { ok: true });
211
244
  }
212
245
  if (sub.length === 2 && (sub[1] === 'enable' || sub[1] === 'disable') && method === 'POST') {
package/dist/cli.js CHANGED
@@ -688,28 +688,38 @@ try {
688
688
  case 'route': {
689
689
  const action = positionals[0];
690
690
  if (action === 'add') {
691
- const [model, providerId] = [positionals[1], positionals[2]];
691
+ const [model, providerId, upstreamModel] = [positionals[1], positionals[2], positionals[3]];
692
692
  if (!model || !providerId) {
693
- console.error('usage: khat route add <model> <provider>');
693
+ console.error('usage: khat route add <model> <provider> [upstream-model]');
694
694
  process.exitCode = 1;
695
695
  break;
696
696
  }
697
- await adminRequest('/_keys/routes', { method: 'POST', body: JSON.stringify({ model, provider: providerId }) });
698
- console.log(`route added: ${model} -> ${providerId}`);
697
+ await adminRequest('/_keys/routes', { method: 'POST', body: JSON.stringify({ model, provider: providerId, ...(upstreamModel ? { upstreamModel } : {}) }) });
698
+ console.log(`route added: ${model}${upstreamModel ? ` -> ${upstreamModel}` : ''} -> ${providerId}`);
699
699
  }
700
700
  else if (action === 'list') {
701
701
  const store = await openInitializedStore();
702
702
  if (!store.config.routes.length)
703
703
  console.log('(no routes)');
704
704
  for (const route of store.config.routes)
705
- console.log(`${route.model} -> ${route.provider}`);
705
+ console.log(`${route.model}${route.upstreamModel ? ` -> ${route.upstreamModel}` : ''} -> ${route.provider}`);
706
706
  }
707
707
  else if (action === 'update') {
708
- const [model, providerId] = [positionals[1], positionals[2]];
709
- if (!model || !providerId)
710
- throw new Error('usage: khat route update <model> <provider>');
711
- await adminRequest(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: JSON.stringify({ provider: providerId }) });
712
- console.log(`route updated: ${model} -> ${providerId}`);
708
+ const [model, providerId, upstreamModel] = [positionals[1], positionals[2], positionals[3]];
709
+ const clearUpstream = flags['clear-upstream'] === true;
710
+ if (!model)
711
+ throw new Error('usage: khat route update <model> [provider] [upstream-model] [--clear-upstream]');
712
+ if (!providerId && !clearUpstream)
713
+ throw new Error('usage: khat route update <model> [provider] [upstream-model] [--clear-upstream]');
714
+ const body = {};
715
+ if (providerId)
716
+ body.provider = providerId;
717
+ if (clearUpstream)
718
+ body.upstreamModel = null;
719
+ else if (upstreamModel)
720
+ body.upstreamModel = upstreamModel;
721
+ await adminRequest(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: JSON.stringify(body) });
722
+ console.log(`route updated: ${model}${clearUpstream ? '' : (upstreamModel ? ` -> ${upstreamModel}` : '')} -> ${providerId ?? '(unchanged)'}`);
713
723
  }
714
724
  else if (action === 'remove') {
715
725
  const model = positionals[1];
package/dist/config.js CHANGED
@@ -35,7 +35,8 @@ export function providerUrl(baseUrl, path) {
35
35
  const base = new URL(baseUrl);
36
36
  const prefix = base.pathname.replace(/\/+$/, '');
37
37
  const suffix = path.startsWith('/') ? path : `/${path}`;
38
- base.pathname = `${prefix}${suffix}` || '/';
38
+ const overlap = prefix.endsWith('/v1') && (suffix === '/v1' || suffix.startsWith('/v1/')) ? '/v1'.length : 0;
39
+ base.pathname = `${prefix}${suffix.slice(overlap)}` || '/';
39
40
  return base;
40
41
  }
41
42
  export function validateConfig(config) {
@@ -78,6 +79,8 @@ export function validateConfig(config) {
78
79
  for (const route of config.routes) {
79
80
  if (!route.model || !route.provider)
80
81
  throw new Error('Invalid route: model and provider are required');
82
+ if (route.upstreamModel !== undefined && (typeof route.upstreamModel !== 'string' || !route.upstreamModel.trim()))
83
+ throw new Error(`Invalid route ${route.model}: upstreamModel must be a non-empty string`);
81
84
  if (route.enabled !== undefined && typeof route.enabled !== 'boolean')
82
85
  throw new Error(`Invalid route ${route.model}: enabled must be boolean`);
83
86
  if (!config.providers.some((item) => item.id === route.provider))
@@ -1,7 +1,7 @@
1
1
  import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
2
2
  import { access, mkdir, readFile, rename, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { saveJsonAtomic, validateConfig } from './config.js';
4
+ import { saveJsonAtomic, validateConfig, defaultConfig } from './config.js';
5
5
  import { Vault } from './vault.js';
6
6
  const FORMAT = 'khat-vault-export';
7
7
  const VERSION = 1;
@@ -144,3 +144,24 @@ export async function importPortable(path, targetDir, protector, password, force
144
144
  await rm(stage, { recursive: true, force: true });
145
145
  }
146
146
  }
147
+ /** Apply a decrypted export payload to a live store: replaces secrets, config and per-key state in place, so references held by the running server stay valid. */
148
+ export async function applyImport(store, payload) {
149
+ validatePayload(payload);
150
+ const config = payload.config ?? structuredClone(defaultConfig);
151
+ // bind/port describe the socket the daemon is already listening on; the import must not claim a different one.
152
+ const listen = { bind: store.config.bind, port: store.config.port };
153
+ Object.assign(store.config, config, listen);
154
+ for (const ref of Object.keys(store.vault.secrets))
155
+ store.vault.delete(ref);
156
+ for (const [ref, value] of Object.entries(payload.secrets))
157
+ store.vault.set(ref, value);
158
+ for (const key of Object.keys(store.states))
159
+ delete store.states[key];
160
+ for (const key of Object.keys(store.counters))
161
+ delete store.counters[key];
162
+ // Vault first: the daemon's config watcher fires on config.json and then resolves secrets from the vault.
163
+ await store.vault.save();
164
+ await store.saveConfig();
165
+ await store.saveState();
166
+ return { secrets: Object.keys(payload.secrets).length, providers: config.providers.length, routes: config.routes.length };
167
+ }
package/dist/router.js CHANGED
@@ -1,11 +1,15 @@
1
1
  export function findRoute(config, model) {
2
+ // Exact model ids take precedence over provider/model shorthand.
3
+ const exact = config.routes.find((route) => route.model === model);
4
+ if (exact)
5
+ return exact;
2
6
  const slash = model.indexOf('/');
3
7
  if (slash > 0) {
4
8
  const providerId = model.slice(0, slash);
5
9
  const routeModel = model.slice(slash + 1);
6
10
  return config.routes.find((route) => route.provider === providerId && route.model === routeModel);
7
11
  }
8
- return config.routes.find((route) => route.model === model);
12
+ return undefined;
9
13
  }
10
14
  export function resolveProvider(config, model) {
11
15
  const route = findRoute(config, model);
@@ -13,6 +17,9 @@ export function resolveProvider(config, model) {
13
17
  return undefined;
14
18
  return config.providers.find((item) => item.id === route.provider);
15
19
  }
20
+ export function routeUpstreamModel(route) {
21
+ return route.upstreamModel ?? route.model;
22
+ }
16
23
  export function isRouteDisabled(config, model) {
17
24
  return findRoute(config, model)?.enabled === false;
18
25
  }
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { watch } from 'node:fs';
3
3
  import { readJsonFile, saveJsonAtomic, validateConfig, providerUrl, defaultTimeouts } from './config.js';
4
4
  import { LogWriter } from './logger.js';
5
5
  import { join, basename } from 'node:path';
6
- import { resolveProvider, isRouteDisabled } from './router.js';
6
+ import { findRoute, routeUpstreamModel, 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';
@@ -101,6 +101,7 @@ export function createKhatServer(options) {
101
101
  }
102
102
  if (!parsed.model || typeof parsed.model !== 'string')
103
103
  return json(res, 400, { error: { message: 'model is required' } });
104
+ const route = findRoute(options.config, parsed.model);
104
105
  const provider = resolveProvider(options.config, parsed.model);
105
106
  if (!provider) {
106
107
  if (isRouteDisabled(options.config, parsed.model))
@@ -109,6 +110,7 @@ export function createKhatServer(options) {
109
110
  }
110
111
  if (provider.protocol !== protocol)
111
112
  return json(res, 400, { error: { message: `Model ${parsed.model} resolves to a ${provider.protocol} provider, but ${req.url} speaks ${protocol}` } });
113
+ const upstreamBody = JSON.stringify({ ...parsed, model: routeUpstreamModel(route) });
112
114
  const tried = new Set();
113
115
  while (true) {
114
116
  const key = selector.select(provider.id, provider.keys.filter((item) => !tried.has(item.id)), states);
@@ -124,7 +126,7 @@ export function createKhatServer(options) {
124
126
  const headerTimer = setTimeout(() => upstreamAbort(abort, 'upstream response header timeout'), timeouts().headerMs);
125
127
  let upstream;
126
128
  try {
127
- upstream = await fetch(providerUrl(provider.baseUrl, req.url), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: new Uint8Array(body), signal: abort.signal });
129
+ upstream = await fetch(providerUrl(provider.baseUrl, req.url), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: upstreamBody, signal: abort.signal });
128
130
  }
129
131
  catch (error) {
130
132
  if (error?.upstreamTimeout)
package/dist/store.js CHANGED
@@ -227,12 +227,17 @@ export async function enableKey(store, providerId, keyId) {
227
227
  delete store.states[`${providerId}/${keyId}`];
228
228
  await store.saveState();
229
229
  }
230
- export async function addRoute(store, model, providerId) {
230
+ function normalizeUpstreamModel(upstreamModel) {
231
+ const normalized = upstreamModel?.trim();
232
+ return normalized || undefined;
233
+ }
234
+ export async function addRoute(store, model, providerId, upstreamModel) {
231
235
  findProvider(store, providerId);
232
236
  const existing = store.config.routes.find((route) => route.model === model);
233
237
  if (existing)
234
238
  throw new Error(`route already exists: ${model} -> ${existing.provider}`);
235
- store.config.routes.push({ model, provider: providerId });
239
+ const normalizedUpstreamModel = normalizeUpstreamModel(upstreamModel);
240
+ store.config.routes.push({ model, provider: providerId, ...(normalizedUpstreamModel && normalizedUpstreamModel !== model ? { upstreamModel: normalizedUpstreamModel } : {}) });
236
241
  try {
237
242
  await persistConfig(store);
238
243
  }
@@ -241,18 +246,31 @@ export async function addRoute(store, model, providerId) {
241
246
  throw error;
242
247
  }
243
248
  }
244
- export async function updateRoute(store, model, providerId) {
245
- findProvider(store, providerId);
249
+ export async function updateRoute(store, model, input) {
250
+ if (input.provider !== undefined)
251
+ findProvider(store, input.provider);
246
252
  const route = store.config.routes.find((item) => item.model === model);
247
253
  if (!route)
248
254
  throw new Error(`unknown route: ${model}`);
249
- const previous = route.provider;
250
- route.provider = providerId;
255
+ const previous = { provider: route.provider, upstreamModel: route.upstreamModel };
256
+ if (input.provider !== undefined)
257
+ route.provider = input.provider;
258
+ if (input.upstreamModel !== undefined) {
259
+ const normalizedUpstreamModel = normalizeUpstreamModel(input.upstreamModel);
260
+ if (normalizedUpstreamModel && normalizedUpstreamModel !== route.model)
261
+ route.upstreamModel = normalizedUpstreamModel;
262
+ else
263
+ delete route.upstreamModel;
264
+ }
251
265
  try {
252
266
  await persistConfig(store);
253
267
  }
254
268
  catch (error) {
255
- route.provider = previous;
269
+ route.provider = previous.provider;
270
+ if (previous.upstreamModel === undefined)
271
+ delete route.upstreamModel;
272
+ else
273
+ route.upstreamModel = previous.upstreamModel;
256
274
  throw error;
257
275
  }
258
276
  }
@@ -60,11 +60,11 @@ export class AdminClient {
60
60
  async probeKey(providerId, keyId) {
61
61
  return this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/probe`, { method: 'POST' });
62
62
  }
63
- async createRoute(model, provider) {
64
- await this.request('/_keys/routes', { method: 'POST', body: { model, provider } });
63
+ async createRoute(model, provider, upstreamModel) {
64
+ await this.request('/_keys/routes', { method: 'POST', body: { model, provider, ...(upstreamModel ? { upstreamModel } : {}) } });
65
65
  }
66
- async updateRoute(model, provider) {
67
- await this.request(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: { provider } });
66
+ async updateRoute(model, provider, upstreamModel) {
67
+ await this.request(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: { ...(provider ? { provider } : {}), ...(upstreamModel !== undefined ? { upstreamModel } : {}) } });
68
68
  }
69
69
  async enableRoute(model) { await this.request(`/_keys/routes/enable?model=${encodeURIComponent(model)}`, { method: 'POST' }); }
70
70
  async disableRoute(model) { await this.request(`/_keys/routes/disable?model=${encodeURIComponent(model)}`, { method: 'POST' }); }
package/dist/tui.js CHANGED
@@ -47,7 +47,7 @@ function RoutesBody({ status, cursor, height }) {
47
47
  return _jsx(Box, { flexDirection: "column", children: routes.length === 0 ? _jsx(Text, { dimColor: true, children: "\u6682\u65E0\u8DEF\u7531 \u00B7 \u7528 khat route add \u6DFB\u52A0" }) : _jsxs(_Fragment, { children: [view.above > 0 && _jsx(Edge, { text: ` ↑ ${view.above} more` }), view.items.map((index) => {
48
48
  const route = routes[index];
49
49
  const selected = index === cursor;
50
- return (_jsx(Text, { color: selected ? 'yellow' : undefined, children: `${selected ? '›' : ' '} ${cell(route.model, 32)}→ ${route.provider}` }, route.model));
50
+ return (_jsx(Text, { color: selected ? 'yellow' : undefined, children: `${selected ? '›' : ' '} ${cell(route.model, 32)}${route.upstreamModel ? ` → ${route.upstreamModel}` : ''} → ${route.provider}` }, route.model));
51
51
  }), view.below > 0 && _jsx(Edge, { text: ` ↓ ${view.below} more` })] }) });
52
52
  }
53
53
  function LogsBody({ logs, filter, cursor, detailOpen, height }) {
package/dist/web-ui.js CHANGED
@@ -123,6 +123,10 @@ tbody tr:hover td{background:var(--surface-2)}
123
123
  <main class="container">
124
124
  <p id="error" class="alert alert-error"></p>
125
125
  <p id="poolWarn" class="alert alert-warn"></p>
126
+ <section class="card">
127
+ <div class="card-head"><div><h2 class="card-title" data-i18n="cardImport">导入加密备份</h2><p class="card-sub" data-i18n="cardImportSub">选择 khat export 导出的 .khat 文件并输入导出密码;导入会替换当前的 Provider、Route 与密钥配置。</p></div></div>
128
+ <form id="importForm" class="toolbar" autocomplete="off"><input id="importFile" type="file" accept=".khat,.json,application/json" required data-i18n-aria="importFileAria" aria-label="选择导出文件"><input id="importPassword" type="password" placeholder="导出密码" data-i18n-ph="phImportPassword" autocomplete="new-password" required><button class="btn btn-primary" data-i18n="btnImport">导入</button></form>
129
+ </section>
126
130
  <div class="stats">
127
131
  <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>
128
132
  <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>
@@ -138,8 +142,8 @@ tbody tr:hover td{background:var(--surface-2)}
138
142
  </section>
139
143
  <section class="card">
140
144
  <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>
141
- <form id="routeForm" class="toolbar" autocomplete="off"><select name="provider" required></select><div class="ms" id="routeModelsMs"><button type="button" class="ms-toggle ms-empty" aria-haspopup="listbox" aria-expanded="false">请先获取模型</button><div class="ms-panel" hidden><input class="ms-search" type="search" placeholder="搜索 model…" data-i18n-ph="msPlaceholder" autocomplete="off"><label class="ms-all"><input type="checkbox" class="ms-all-cb"><span data-i18n="msSelectAll">全选</span></label><div class="ms-list" role="listbox"></div></div></div><button type="button" id="routeModels" class="btn btn-secondary" data-i18n="btnLoadModels">获取模型</button><button class="btn btn-primary" data-i18n="btnAddRoute">批量添加 Route</button></form>
142
- <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>
145
+ <form id="routeForm" class="toolbar" autocomplete="off"><select name="provider"></select><button type="button" id="clearRouteProvider" class="btn btn-secondary" data-i18n="btnClearProvider">显示全部 Provider</button><div class="ms" id="routeModelsMs"><button type="button" class="ms-toggle ms-empty" aria-haspopup="listbox" aria-expanded="false">请先获取模型</button><div class="ms-panel" hidden><input class="ms-search" type="search" placeholder="搜索 model…" data-i18n-ph="msPlaceholder" autocomplete="off"><label class="ms-all"><input type="checkbox" class="ms-all-cb"><span data-i18n="msSelectAll">全选</span></label><div class="ms-list" role="listbox"></div></div></div><button type="button" id="routeModels" class="btn btn-secondary" data-i18n="btnLoadModels">获取模型</button><button class="btn btn-primary" data-i18n="btnAddRoute">批量添加 Route</button></form>
146
+ <div class="table-wrap"><table><thead><tr><th data-i18n="thModel">Model</th><th>Upstream Model</th><th data-i18n="thProvider">Provider</th><th data-i18n="thStatus">Status</th><th></th></tr></thead><tbody id="routes"></tbody></table></div>
143
147
  </section>
144
148
  <section class="card">
145
149
  <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>
@@ -155,15 +159,15 @@ tbody tr:hover td{background:var(--surface-2)}
155
159
  const $=s=>document.querySelector(s), error=$('#error');
156
160
  const NS='http://www.w3.org/2000/svg';
157
161
  const pad2=n=>String(n).padStart(2,'0');
158
- const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all',routeModels:[],routeSelected:new Set(),routeFilter:'',routeOpen:false};
162
+ const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all',routeModels:[],routeSelected:new Set(),routeFilter:'',routeOpen:false,routeProvider:''};
159
163
  const I18N={
160
- 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:'获取模型',msEmpty:'请先获取模型',msLoading:'加载中…',msPlaceholder:'搜索 model…',msSelectAll:'全选',msSelected:'已选 {n} / {total}',msNoMatch:'无匹配模型',msNeedProvider:'请先选择 Provider',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:'请求成功 / 失败趋势'},
161
- 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',msEmpty:'Load models first',msLoading:'Loading…',msPlaceholder:'Search model…',msSelectAll:'Select all',msSelected:'{n} / {total} selected',msNoMatch:'No matching models',msNeedProvider:'Select a provider first',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'}
164
+ 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 / 错误原因…',allProviders:'全部 Provider',btnClearProvider:'显示全部 Provider',allStatus:'全部状态',onlyAvail:'仅可用',onlyUnavail:'仅不可用',cardRoutes:'Routes',cardRoutesSub:'模型到 Provider 的转发映射规则。',phModel:'model',btnAddRoute:'批量添加 Route',btnLoadModels:'获取模型',msEmpty:'请先获取模型',msLoading:'加载中…',msPlaceholder:'搜索 model…',msSelectAll:'全选',msSelected:'已选 {n} / {total}',msNoMatch:'无匹配模型',msNeedProvider:'请先选择 Provider',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:'暂无路由配置',cardImport:'导入加密备份',cardImportSub:'选择 khat export 导出的 .khat 文件并输入导出密码;导入会替换当前的 Provider、Route 与密钥配置。',phImportPassword:'导出密码',btnImport:'导入',confirmImport:'导入将替换当前的 Provider、Route 与密钥配置,且无法撤销。确定继续?',importFileAria:'选择导出文件',imported:'导入成功:{s} 个密钥 · {p} 个 Provider · {r} 条 Route',errChooseFile:'请先选择导出文件',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:'请求成功 / 失败趋势'},
165
+ 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…',allProviders:'All providers',btnClearProvider:'Show all providers',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',msEmpty:'Load models first',msLoading:'Loading…',msPlaceholder:'Search model…',msSelectAll:'Select all',msSelected:'{n} / {total} selected',msNoMatch:'No matching models',msNeedProvider:'Select a provider first',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',cardImport:'Import encrypted backup',cardImportSub:'Pick a .khat file produced by khat export and enter its password; importing replaces the current providers, routes and keys.',phImportPassword:'Export password',btnImport:'Import',confirmImport:'Importing replaces the current providers, routes and keys. This cannot be undone. Continue?',importFileAria:'Choose export file',imported:'Imported: {s} secrets · {p} providers · {r} routes',errChooseFile:'Choose an export file first',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'}
162
166
  };
163
167
  let lang=(()=>{try{return localStorage.getItem('khatLang')==='en'?'en':'zh'}catch(e){return 'zh'}})();
164
168
  function t(key){return (I18N[lang]&&I18N[lang][key])||I18N.zh[key]||key}
165
169
  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}
166
- 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();renderRouteModels();renderLogs();drawTraffic();const b=$('#langToggle');if(b)b.textContent=l==='zh'?'EN':'中'}
170
+ 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))});const allProvider=$('#routeForm [name=provider] option[value=""]');if(allProvider)allProvider.textContent=t('allProviders');renderConnection();renderStats();renderProviders();renderRoutes();renderRouteModels();renderLogs();drawTraffic();const b=$('#langToggle');if(b)b.textContent=l==='zh'?'EN':'中'}
167
171
  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)}
168
172
  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}
169
173
  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'))}
@@ -175,7 +179,7 @@ function errText(k){const http=k.lastError&&k.lastError.http;if(http===undefined
175
179
  function btn(label,className,onclick){const b=document.createElement('button');b.type='button';b.className=className;b.textContent=label;b.onclick=onclick;return b}
176
180
  function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message;toast(e.message)}}}
177
181
  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}
178
- 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}
182
+ function syncProviderSelect(sel,providers,allowEmpty){const ids=providers.map(p=>p.id).join(',');if(sel.dataset.ids===ids&&(!allowEmpty||sel.dataset.allowEmpty==='1'))return;sel.dataset.ids=ids;if(allowEmpty)sel.dataset.allowEmpty='1';const prev=sel.value;const options=providers.map(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.id;return o});if(allowEmpty){const o=document.createElement('option');o.value='';o.textContent=t('allProviders');options.unshift(o)}sel.replaceChildren(...options);if(ids.split(',').includes(prev)||allowEmpty&&!prev)sel.value=prev}
179
183
  function renderConnection(){if(!state.status)return;const c=$('#connection');c.replaceChildren(Object.assign(document.createElement('i'),{className:'dot'}),document.createTextNode(' '+state.status.listen))}
180
184
  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):'—'}
181
185
  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}
@@ -231,11 +235,11 @@ function renderProviders(){
231
235
  }
232
236
  function renderRoutes(){
233
237
  const tb=$('#routes');tb.replaceChildren();
234
- const rs=state.status?state.status.routes:[];
235
- 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}
236
- for(const rt of rs){
237
- const tr=document.createElement('tr');
238
- tr.append(cell(rt.model,'mono-cell'),cell(rt.provider),statusCell(rt.enabled!==false,rt.enabled!==false?t('avail'):t('unavail')));
238
+ const rs=(state.status?state.status.routes:[]).filter(rt=>!state.routeProvider||rt.provider===state.routeProvider);
239
+ if(!rs.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=5;ed.textContent=t('noRoutes');er.append(ed);tb.append(er);return}
240
+ for(const rt of rs){
241
+ const tr=document.createElement('tr');
242
+ tr.append(cell(rt.model,'mono-cell'),cell(rt.upstreamModel||rt.model,'mono-cell'),cell(rt.provider),statusCell(rt.enabled!==false,rt.enabled!==false?t('avail'):t('unavail')));
239
243
  const d=document.createElement('td');
240
244
  const toggle=rt.enabled!==false?'disable':'enable';
241
245
  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()})));
@@ -352,7 +356,10 @@ async function load(){
352
356
  state.status=statusResultValue.value;
353
357
  renderConnection();
354
358
  syncProviderSelect($('#keyForm [name=provider]'),state.status.providers);
355
- syncProviderSelect($('#routeForm [name=provider]'),state.status.providers);
359
+ syncProviderSelect($('#routeForm [name=provider]'),state.status.providers,true);
360
+ const routeProvider=$('#routeForm [name=provider]');
361
+ if(state.routeProvider&&state.status.providers.some(p=>p.id===state.routeProvider))routeProvider.value=state.routeProvider;
362
+ else {state.routeProvider='';routeProvider.value='';}
356
363
  renderProviders();
357
364
  renderRoutes();
358
365
  }else{error.textContent=statusResultValue.reason?.message||String(statusResultValue.reason);toast(error.textContent)}
@@ -372,7 +379,8 @@ $('#langToggle').onclick=()=>applyLang(lang==='zh'?'en':'zh');
372
379
  applyTheme(document.documentElement.getAttribute('data-theme')||'light');
373
380
  applyLang(lang);
374
381
  $('#routeModels').onclick=()=>loadModels();
375
- $('#routeForm [name=provider]').onchange=clearRouteModels;
382
+ $('#routeForm [name=provider]').onchange=e=>{state.routeProvider=e.target.value;clearRouteModels();renderRoutes()};
383
+ $('#clearRouteProvider').onclick=()=>{state.routeProvider='';$('#routeForm [name=provider]').value='';clearRouteModels();renderRoutes()};
376
384
  $('#routeModelsMs .ms-toggle').onclick=e=>{e.preventDefault();if(!state.routeModels.length)return;openRouteModels(!state.routeOpen)};
377
385
  $('#routeModelsMs .ms-search').oninput=e=>{state.routeFilter=e.target.value;buildRouteModelList();syncRouteModelHeader()};
378
386
  $('#routeModelsMs .ms-all-cb').onchange=e=>{const kw=state.routeFilter.trim().toLowerCase();const items=state.routeModels.filter(m=>!kw||m.toLowerCase().includes(kw));if(e.target.checked)items.forEach(m=>state.routeSelected.add(m));else items.forEach(m=>state.routeSelected.delete(m));buildRouteModelList();syncRouteModelHeader()};
@@ -380,7 +388,8 @@ document.addEventListener('click',e=>{if(!state.routeOpen)return;const ms=$('#ro
380
388
  document.addEventListener('keydown',e=>{if(e.key==='Escape'&&state.routeOpen){openRouteModels(false);const tg=$('#routeModelsMs .ms-toggle');if(tg)tg.focus()}});
381
389
  $('#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()});
382
390
  $('#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()});
383
- $('#routeForm').onsubmit=run(async e=>{e.preventDefault();const provider=e.target.provider.value;const models=[...state.routeSelected];if(!models.length){toast(t('msEmpty'));return}for(const model of models)await api('/_keys/routes',{method:'POST',body:JSON.stringify({model,provider})});clearRouteModels();e.target.reset();toast(t('saved'),'ok');load()});
391
+ $('#routeForm').onsubmit=run(async e=>{e.preventDefault();const provider=e.target.provider.value;const models=[...state.routeSelected];if(!provider){toast(t('msNeedProvider'));return}if(!models.length){toast(t('msEmpty'));return}for(const model of models)await api('/_keys/routes',{method:'POST',body:JSON.stringify({model,provider})});clearRouteModels();e.target.reset();toast(t('saved'),'ok');load()});
392
+ $('#importForm').onsubmit=run(async e=>{e.preventDefault();const file=$('#importFile').files[0];if(!file){toast(t('errChooseFile'));return}if(!confirm(t('confirmImport')))return;const content=await file.text();const r=await api('/_keys/import',{method:'POST',body:JSON.stringify({content,password:$('#importPassword').value})});e.target.reset();toast(tf('imported',{s:r.secrets,p:r.providers,r:r.routes}),'ok',6000);load()});
384
393
  $('#keySearch').oninput=e=>{state.keyKeyword=e.target.value;renderProviders()};
385
394
  $('#keyStatus').onchange=e=>{state.keyStatus=e.target.value;renderProviders()};
386
395
  $('#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.4",
3
+ "version": "0.2.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },