@alilis/k-hat 0.2.3 → 0.2.5

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
@@ -3,6 +3,8 @@ import { LogWriter } from './logger.js';
3
3
  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
+ import { providerUrl } from './config.js';
7
+ import { findRoute, routeUpstreamModel } from './router.js';
6
8
  // Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
7
9
  // only from loopback and behind the proxy access token, so a local non-loopback
8
10
  // client or a process without the token cannot mutate config/state/vault.
@@ -43,7 +45,7 @@ async function listProviderModels(store, providerId) {
43
45
  if (!secret)
44
46
  throw new Error('provider key secret is missing');
45
47
  const headers = upstreamHeaders(provider.protocol, secret);
46
- const response = await fetch(new URL('/v1/models', provider.baseUrl), { headers, signal: AbortSignal.timeout(10_000) });
48
+ const response = await fetch(providerUrl(provider.baseUrl, '/v1/models'), { headers, signal: AbortSignal.timeout(10_000) });
47
49
  if (!response.ok)
48
50
  throw new Error(`model query failed with HTTP ${response.status}`);
49
51
  const body = await response.json();
@@ -75,7 +77,12 @@ function buildStatus(store) {
75
77
  };
76
78
  })
77
79
  })),
78
- routes: store.config.routes.map((route) => route.enabled === false ? { ...route, enabled: false } : { model: route.model, provider: route.provider })
80
+ routes: store.config.routes.map((route) => ({
81
+ model: route.model,
82
+ provider: route.provider,
83
+ ...(route.upstreamModel ? { upstreamModel: route.upstreamModel } : {}),
84
+ ...(route.enabled === false ? { enabled: false } : {})
85
+ }))
79
86
  };
80
87
  }
81
88
  async function route(req, res, store, sessionAuthorized = false) {
@@ -180,13 +187,18 @@ async function route(req, res, store, sessionAuthorized = false) {
180
187
  if (!provider || !key)
181
188
  throw new Error('unknown provider or key');
182
189
  const secret = store.vault.get(key.vaultRef);
183
- const model = store.config.routes.find((item) => item.provider === provider.id)?.model;
184
- if (!secret || !model)
190
+ const requestedModel = url.searchParams.get('model');
191
+ const route = requestedModel
192
+ ? findRoute(store.config, requestedModel)
193
+ : store.config.routes.find((item) => item.provider === provider.id);
194
+ if (!secret || !route || route.provider !== provider.id)
185
195
  throw new Error('a key and route model are required for probing');
196
+ const model = routeUpstreamModel(route);
186
197
  const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
187
198
  const headers = upstreamHeaders(provider.protocol, secret, true);
188
199
  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 };
189
- const response = await fetch(new URL(path, provider.baseUrl), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
200
+ const response = await fetch(providerUrl(provider.baseUrl, path), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
201
+ await response.arrayBuffer();
190
202
  if (response.ok)
191
203
  await store.mutate(() => enableKey(store, provider.id, key.id));
192
204
  return json(res, response.ok ? 200 : 502, { ok: response.ok, status: response.status });
@@ -197,15 +209,24 @@ async function route(req, res, store, sessionAuthorized = false) {
197
209
  const body = await readJsonBody(req);
198
210
  if (!body.model || !body.provider)
199
211
  throw new Error('model and provider are required');
200
- await store.mutate(() => addRoute(store, body.model, body.provider));
212
+ if (body.upstreamModel !== undefined && (typeof body.upstreamModel !== 'string' || !body.upstreamModel.trim()))
213
+ throw new Error('upstreamModel must be a non-empty string');
214
+ await store.mutate(() => addRoute(store, body.model, body.provider, body.upstreamModel));
201
215
  return json(res, 200, { ok: true });
202
216
  }
203
217
  if (sub.length === 2 && method === 'PUT') {
204
218
  const body = await readJsonBody(req);
205
- if (body.provider !== undefined)
206
- await store.mutate(() => updateRoute(store, decodeURIComponent(sub[1]), body.provider));
219
+ if (body.provider !== undefined && typeof body.provider !== 'string')
220
+ throw new Error('provider must be a string');
221
+ if (body.upstreamModel !== undefined && body.upstreamModel !== null && (typeof body.upstreamModel !== 'string' || !body.upstreamModel.trim()))
222
+ throw new Error('upstreamModel must be a non-empty string or null');
223
+ if (body.enabled !== undefined && typeof body.enabled !== 'boolean')
224
+ throw new Error('enabled must be a boolean');
225
+ const model = decodeURIComponent(sub[1]);
226
+ if (body.provider !== undefined || body.upstreamModel !== undefined)
227
+ await store.mutate(() => updateRoute(store, model, { provider: body.provider, upstreamModel: body.upstreamModel }));
207
228
  if (body.enabled !== undefined)
208
- await store.mutate(() => setRouteEnabled(store, decodeURIComponent(sub[1]), body.enabled));
229
+ await store.mutate(() => setRouteEnabled(store, model, body.enabled));
209
230
  return json(res, 200, { ok: true });
210
231
  }
211
232
  if (sub.length === 2 && (sub[1] === 'enable' || sub[1] === 'disable') && method === 'POST') {
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { createInterface } from 'node:readline/promises';
9
- import { saveJsonAtomic, defaultConfig } from './config.js';
9
+ import { saveJsonAtomic, providerUrl, defaultConfig } from './config.js';
10
10
  import { createKhatServer } from './server.js';
11
11
  import { openStore, maskSecret } from './store.js';
12
12
  import { Vault, generateAccessToken, ACCESS_TOKEN_REF } from './vault.js';
@@ -547,7 +547,7 @@ try {
547
547
  const protocol = (flagString(flags, 'protocol') ?? 'openai');
548
548
  if (protocol !== 'openai' && protocol !== 'anthropic')
549
549
  throw new Error(`unsupported protocol '${protocol}' (use 'openai' or 'anthropic')`);
550
- await adminRequest('/_keys/providers', { method: 'POST', body: JSON.stringify({ id, name: flagString(flags, 'name'), protocol, baseUrl: baseUrl.replace(/\/+$/, '') }) });
550
+ await adminRequest('/_keys/providers', { method: 'POST', body: JSON.stringify({ id, name: flagString(flags, 'name'), protocol, baseUrl }) });
551
551
  console.log(`added provider ${id} -> ${baseUrl}`);
552
552
  }
553
553
  else if (action === 'list') {
@@ -665,7 +665,7 @@ try {
665
665
  ? JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] })
666
666
  : JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false });
667
667
  console.log(`probing ${provider.baseUrl}${path} with model ${model}…`);
668
- const response = await fetch(new URL(path, provider.baseUrl), {
668
+ const response = await fetch(providerUrl(provider.baseUrl, path), {
669
669
  method: 'POST',
670
670
  headers,
671
671
  body,
@@ -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
@@ -31,6 +31,13 @@ export async function saveJsonAtomic(path, value) {
31
31
  function isLoopbackHost(hostname) {
32
32
  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
33
33
  }
34
+ export function providerUrl(baseUrl, path) {
35
+ const base = new URL(baseUrl);
36
+ const prefix = base.pathname.replace(/\/+$/, '');
37
+ const suffix = path.startsWith('/') ? path : `/${path}`;
38
+ base.pathname = `${prefix}${suffix}` || '/';
39
+ return base;
40
+ }
34
41
  export function validateConfig(config) {
35
42
  if (!config || config.version !== 1 || !Array.isArray(config.providers) || !Array.isArray(config.routes)) {
36
43
  throw new Error('Invalid configuration: expected version 1 with providers and routes');
@@ -57,8 +64,8 @@ export function validateConfig(config) {
57
64
  }
58
65
  if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname))
59
66
  throw new Error(`Provider ${provider.id}: baseUrl must use https (plain http is only allowed on loopback hosts)`);
60
- if (url.pathname !== '/')
61
- throw new Error(`Provider ${provider.id}: baseUrl must not contain a path (strip trailing /v1 etc.)`);
67
+ if (url.search || url.hash)
68
+ throw new Error(`Provider ${provider.id}: baseUrl must not contain a query or hash`);
62
69
  for (const key of provider.keys) {
63
70
  if (!key.id || !Number.isInteger(key.weight) || key.weight < 1)
64
71
  throw new Error(`Invalid key in provider ${provider.id}`);
@@ -71,6 +78,8 @@ export function validateConfig(config) {
71
78
  for (const route of config.routes) {
72
79
  if (!route.model || !route.provider)
73
80
  throw new Error('Invalid route: model and provider are required');
81
+ if (route.upstreamModel !== undefined && (typeof route.upstreamModel !== 'string' || !route.upstreamModel.trim()))
82
+ throw new Error(`Invalid route ${route.model}: upstreamModel must be a non-empty string`);
74
83
  if (route.enabled !== undefined && typeof route.enabled !== 'boolean')
75
84
  throw new Error(`Invalid route ${route.model}: enabled must be boolean`);
76
85
  if (!config.providers.some((item) => item.id === route.provider))
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
@@ -1,9 +1,9 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { watch } from 'node:fs';
3
- import { readJsonFile, saveJsonAtomic, validateConfig, defaultTimeouts } from './config.js';
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(new URL(req.url, provider.baseUrl), { 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
@@ -12,6 +12,9 @@ function requireValidId(kind, id) {
12
12
  if (!ID_PATTERN.test(id))
13
13
  throw new Error(`${kind} id '${id}' is invalid: use letters, digits, '-' or '_'`);
14
14
  }
15
+ function normalizeBaseUrl(baseUrl) {
16
+ return baseUrl.replace(/\/+$/, '');
17
+ }
15
18
  export async function openStore(dir, protector) {
16
19
  await mkdir(dir, { recursive: true, mode: 0o700 });
17
20
  const configPath = join(dir, 'config.json');
@@ -100,7 +103,7 @@ export async function addProvider(store, input) {
100
103
  requireValidId('provider', input.id);
101
104
  if (store.config.providers.some((item) => item.id === input.id))
102
105
  throw new Error(`provider already exists: ${input.id}`);
103
- store.config.providers.push({ id: input.id, name: input.name?.trim() || input.id, protocol: input.protocol, baseUrl: input.baseUrl, keys: [] });
106
+ store.config.providers.push({ id: input.id, name: input.name?.trim() || input.id, protocol: input.protocol, baseUrl: normalizeBaseUrl(input.baseUrl), keys: [] });
104
107
  try {
105
108
  await persistConfig(store);
106
109
  }
@@ -117,7 +120,7 @@ export async function updateProvider(store, id, input) {
117
120
  if (input.protocol !== undefined)
118
121
  provider.protocol = input.protocol;
119
122
  if (input.baseUrl !== undefined)
120
- provider.baseUrl = input.baseUrl.replace(/\/+$/, '');
123
+ provider.baseUrl = normalizeBaseUrl(input.baseUrl);
121
124
  try {
122
125
  await persistConfig(store);
123
126
  }
@@ -133,6 +136,7 @@ export async function removeProvider(store, id) {
133
136
  for (const key of provider.keys) {
134
137
  store.vault.delete(key.vaultRef);
135
138
  delete store.states[`${id}/${key.id}`];
139
+ delete store.counters[`${id}/${key.id}`];
136
140
  }
137
141
  await persistConfig(store);
138
142
  await store.vault.save();
@@ -223,12 +227,17 @@ export async function enableKey(store, providerId, keyId) {
223
227
  delete store.states[`${providerId}/${keyId}`];
224
228
  await store.saveState();
225
229
  }
226
- 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) {
227
235
  findProvider(store, providerId);
228
236
  const existing = store.config.routes.find((route) => route.model === model);
229
237
  if (existing)
230
238
  throw new Error(`route already exists: ${model} -> ${existing.provider}`);
231
- 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 } : {}) });
232
241
  try {
233
242
  await persistConfig(store);
234
243
  }
@@ -237,18 +246,31 @@ export async function addRoute(store, model, providerId) {
237
246
  throw error;
238
247
  }
239
248
  }
240
- export async function updateRoute(store, model, providerId) {
241
- findProvider(store, providerId);
249
+ export async function updateRoute(store, model, input) {
250
+ if (input.provider !== undefined)
251
+ findProvider(store, input.provider);
242
252
  const route = store.config.routes.find((item) => item.model === model);
243
253
  if (!route)
244
254
  throw new Error(`unknown route: ${model}`);
245
- const previous = route.provider;
246
- 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
+ }
247
265
  try {
248
266
  await persistConfig(store);
249
267
  }
250
268
  catch (error) {
251
- route.provider = previous;
269
+ route.provider = previous.provider;
270
+ if (previous.upstreamModel === undefined)
271
+ delete route.upstreamModel;
272
+ else
273
+ route.upstreamModel = previous.upstreamModel;
252
274
  throw error;
253
275
  }
254
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 }) {
@@ -180,6 +180,8 @@ export function Tui({ client }) {
180
180
  }
181
181
  else if (row && input === 'd')
182
182
  setConfirm({ label: `删除 key ${row.providerId}/${row.keyId}`, run: () => client.deleteKey(row.providerId, row.keyId) });
183
+ else if (row && input === 'D')
184
+ setConfirm({ label: `删除 Provider ${row.providerId}`, run: () => client.deleteProvider(row.providerId) });
183
185
  }
184
186
  else if (section === 'routes') {
185
187
  const route = routes[routeCursor];
@@ -221,7 +223,7 @@ export function Tui({ client }) {
221
223
  else
222
224
  body = _jsx(DiagnosticsBody, { snapshot: snapshot });
223
225
  const HELP = {
224
- overview: 'Tab/1-4 区块 · ↑↓ 选择 · e 启用 · p 探活 · d 删除 · Enter 详情 · r 刷新 · q 退出',
226
+ overview: 'Tab/1-4 区块 · ↑↓ 选择 · e 启用 · p 探活 · d 删除 key · D 删除 Provider · Enter 详情 · r 刷新 · q 退出',
225
227
  routes: 'Tab/1-4 区块 · ↑↓ 选择 · d 删除 · r 刷新 · q 退出',
226
228
  logs: 'Tab/1-4 区块 · ↑↓ 选择 · f 过滤 · Enter 详情 · r 刷新 · q 退出',
227
229
  diagnostics: 'Tab/1-4 区块 · t 轮换令牌 · r 刷新 · q 退出'
package/dist/web-ui.js CHANGED
@@ -139,7 +139,7 @@ tbody tr:hover td{background:var(--surface-2)}
139
139
  <section class="card">
140
140
  <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
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>
142
+ <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
143
  </section>
144
144
  <section class="card">
145
145
  <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>
@@ -232,10 +232,10 @@ function renderProviders(){
232
232
  function renderRoutes(){
233
233
  const tb=$('#routes');tb.replaceChildren();
234
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')));
235
+ 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}
236
+ for(const rt of rs){
237
+ const tr=document.createElement('tr');
238
+ 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
239
  const d=document.createElement('td');
240
240
  const toggle=rt.enabled!==false?'disable':'enable';
241
241
  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()})));
@@ -344,20 +344,27 @@ async function loadModels(){
344
344
  }catch(e){error.textContent=e.message;toast(e.message);syncRouteModelHeader()}
345
345
  }
346
346
  async function load(){
347
- try{
348
- await exchange();
349
- const results=await Promise.all([api('/_keys/status'),api('/_keys/logs?tail='+state.logTail)]);
350
- state.status=results[0];state.logs=results[1].logs||[];
351
- error.textContent='';
347
+ try{await exchange()}catch(e){error.textContent=e.message;toast(e.message);return}
348
+ const statusResult=await Promise.allSettled([api('/_keys/status'),api('/_keys/logs?tail='+state.logTail)]);
349
+ const statusResultValue=statusResult[0];
350
+ const logsResultValue=statusResult[1];
351
+ if(statusResultValue.status==='fulfilled'){
352
+ state.status=statusResultValue.value;
352
353
  renderConnection();
353
- renderStats();
354
354
  syncProviderSelect($('#keyForm [name=provider]'),state.status.providers);
355
355
  syncProviderSelect($('#routeForm [name=provider]'),state.status.providers);
356
356
  renderProviders();
357
357
  renderRoutes();
358
+ }else{error.textContent=statusResultValue.reason?.message||String(statusResultValue.reason);toast(error.textContent)}
359
+ if(logsResultValue.status==='fulfilled'){
360
+ state.logs=logsResultValue.value.logs||[];
358
361
  renderLogs();
359
362
  drawTraffic();
360
- }catch(e){error.textContent=e.message;toast(e.message)}
363
+ }else if(statusResultValue.status==='fulfilled'){
364
+ error.textContent=logsResultValue.reason?.message||String(logsResultValue.reason);toast(error.textContent,'warn');
365
+ }
366
+ if(statusResultValue.status==='fulfilled'&&logsResultValue.status==='fulfilled')error.textContent='';
367
+ if(statusResultValue.status==='fulfilled')renderStats();
361
368
  }
362
369
  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'))}
363
370
  $('#themeToggle').onclick=()=>applyTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark');
@@ -381,7 +388,7 @@ $('#logStatus').onchange=e=>{state.logStatus=e.target.value;renderLogs()};
381
388
  $('#logTail').onchange=e=>{state.logTail=e.target.value;load()};
382
389
  $('#refresh').onclick=()=>load();
383
390
  window.addEventListener('resize',drawTraffic);
384
- load();setInterval(load,30000);
391
+ load();setInterval(load,5000);
385
392
  </script></body></html>`;
386
393
  export class UiSessions {
387
394
  tickets = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alilis/k-hat",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },