@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 +30 -9
- package/dist/cli.js +23 -13
- package/dist/config.js +11 -2
- package/dist/router.js +8 -1
- package/dist/server.js +5 -3
- package/dist/store.js +31 -9
- package/dist/tui-client.js +4 -4
- package/dist/tui.js +4 -2
- package/dist/web-ui.js +20 -13
- package/package.json +1 -1
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(
|
|
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) =>
|
|
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
|
|
184
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
@@ -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.
|
|
61
|
-
throw new Error(`Provider ${provider.id}: baseUrl must not contain a
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
241
|
-
|
|
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
|
-
|
|
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
|
}
|
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 }) {
|
|
@@ -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
|
-
|
|
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()})));
|
|
@@ -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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
}
|
|
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,
|
|
391
|
+
load();setInterval(load,5000);
|
|
385
392
|
</script></body></html>`;
|
|
386
393
|
export class UiSessions {
|
|
387
394
|
tickets = new Map();
|