@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 +27 -7
- package/dist/cli.js +20 -10
- package/dist/config.js +2 -0
- package/dist/router.js +8 -1
- package/dist/server.js +4 -2
- package/dist/store.js +25 -7
- package/dist/tui-client.js +4 -4
- package/dist/tui.js +1 -1
- package/dist/web-ui.js +5 -5
- package/package.json +1 -1
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) =>
|
|
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
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
245
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tui-client.js
CHANGED
|
@@ -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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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()})));
|