@alilis/k-hat 0.2.4 → 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
@@ -4,6 +4,7 @@ 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';
7
8
  // Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
8
9
  // only from loopback and behind the proxy access token, so a local non-loopback
9
10
  // client or a process without the token cannot mutate config/state/vault.
@@ -76,7 +77,12 @@ function buildStatus(store) {
76
77
  };
77
78
  })
78
79
  })),
79
- 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
+ }))
80
86
  };
81
87
  }
82
88
  async function route(req, res, store, sessionAuthorized = false) {
@@ -181,13 +187,18 @@ async function route(req, res, store, sessionAuthorized = false) {
181
187
  if (!provider || !key)
182
188
  throw new Error('unknown provider or key');
183
189
  const secret = store.vault.get(key.vaultRef);
184
- const model = store.config.routes.find((item) => item.provider === provider.id)?.model;
185
- 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)
186
195
  throw new Error('a key and route model are required for probing');
196
+ const model = routeUpstreamModel(route);
187
197
  const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
188
198
  const headers = upstreamHeaders(provider.protocol, secret, true);
189
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 };
190
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();
191
202
  if (response.ok)
192
203
  await store.mutate(() => enableKey(store, provider.id, key.id));
193
204
  return json(res, response.ok ? 200 : 502, { ok: response.ok, status: response.status });
@@ -198,15 +209,24 @@ async function route(req, res, store, sessionAuthorized = false) {
198
209
  const body = await readJsonBody(req);
199
210
  if (!body.model || !body.provider)
200
211
  throw new Error('model and provider are required');
201
- 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));
202
215
  return json(res, 200, { ok: true });
203
216
  }
204
217
  if (sub.length === 2 && method === 'PUT') {
205
218
  const body = await readJsonBody(req);
206
- if (body.provider !== undefined)
207
- 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 }));
208
228
  if (body.enabled !== undefined)
209
- await store.mutate(() => setRouteEnabled(store, decodeURIComponent(sub[1]), body.enabled));
229
+ await store.mutate(() => setRouteEnabled(store, model, body.enabled));
210
230
  return json(res, 200, { ok: true });
211
231
  }
212
232
  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
@@ -78,6 +78,8 @@ export function validateConfig(config) {
78
78
  for (const route of config.routes) {
79
79
  if (!route.model || !route.provider)
80
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`);
81
83
  if (route.enabled !== undefined && typeof route.enabled !== 'boolean')
82
84
  throw new Error(`Invalid route ${route.model}: enabled must be boolean`);
83
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
@@ -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
@@ -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()})));
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.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },