@alilis/k-hat 0.1.0 → 0.2.1
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/README.md +3 -3
- package/dist/admin.js +50 -9
- package/dist/cli.js +37 -8
- package/dist/config.js +7 -0
- package/dist/router.js +13 -5
- package/dist/selector.js +1 -1
- package/dist/server.js +13 -5
- package/dist/store.js +41 -0
- package/dist/tui-client.js +5 -0
- package/dist/tui-state.js +59 -0
- package/dist/tui.js +180 -234
- package/dist/web-ui.js +83 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,14 +86,14 @@ khat stop
|
|
|
86
86
|
|
|
87
87
|
| 命令 | 说明 |
|
|
88
88
|
|---|---|
|
|
89
|
-
| `khat init` | 首次初始化:创建密钥库 + 访问令牌 +
|
|
90
|
-
| `khat start` | 后台启动代理与 supervisor;使用 `--foreground`
|
|
89
|
+
| `khat init` | 首次初始化:创建密钥库 + 访问令牌 + 默认配置;可用 `--port <n>` 指定监听端口 |
|
|
90
|
+
| `khat start` | 后台启动代理与 supervisor;使用 `--foreground` 以前台模式诊断,使用 `--port <n>` 修改并持久化监听端口 |
|
|
91
91
|
| `khat stop` | 请求后台代理优雅停止 |
|
|
92
92
|
| `khat status` | 查看 Provider、key、路由及 key 健康状态 |
|
|
93
93
|
| `khat doctor` | 检测本机 Agent Tool 配置并输出接入提示 |
|
|
94
94
|
| `khat log [--tail <n>]` | 查看最近的脱敏请求日志 |
|
|
95
95
|
| `khat ui` | 签发一次性票据并打开本地管理页 |
|
|
96
|
-
| `khat tui` |
|
|
96
|
+
| `khat tui` | 终端监控面板:跨 Provider 的 key 健康表(不可用排最前,`e` 一键恢复、`p` 探活)、请求日志与诊断,Tab/1-4 切换区块 |
|
|
97
97
|
|
|
98
98
|
> Provider、key 和路由的新增、更新、删除与恢复等管理变更通过本地 Admin API 执行;若 daemon 尚未运行,命令会自动启动它。
|
|
99
99
|
|
package/dist/admin.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addProvider, updateProvider, removeProvider, addKey, updateKey, removeKey, enableKey, addRoute, updateRoute, removeRoute, maskSecret } from './store.js';
|
|
1
|
+
import { addProvider, updateProvider, removeProvider, addKey, updateKey, removeKey, enableKey, setKeyEnabled, addRoute, updateRoute, setRouteEnabled, removeRoute, maskSecret } from './store.js';
|
|
2
2
|
import { LogWriter } from './logger.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { UiSessions, serveUi } from './web-ui.js';
|
|
@@ -8,6 +8,15 @@ import { ACCESS_TOKEN_REF, generateAccessToken } from './vault.js';
|
|
|
8
8
|
// client or a process without the token cannot mutate config/state/vault.
|
|
9
9
|
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
10
10
|
const uiSessions = new UiSessions();
|
|
11
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
12
|
+
function upstreamHeaders(protocol, secret, contentType = false) {
|
|
13
|
+
const headers = protocol === 'anthropic'
|
|
14
|
+
? { 'x-api-key': secret, 'anthropic-version': ANTHROPIC_VERSION }
|
|
15
|
+
: { authorization: `Bearer ${secret}` };
|
|
16
|
+
if (contentType)
|
|
17
|
+
headers['content-type'] = 'application/json';
|
|
18
|
+
return headers;
|
|
19
|
+
}
|
|
11
20
|
function json(res, status, value) {
|
|
12
21
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
13
22
|
res.end(JSON.stringify(value));
|
|
@@ -23,6 +32,25 @@ async function readJsonBody(req) {
|
|
|
23
32
|
throw new Error('Invalid JSON body');
|
|
24
33
|
}
|
|
25
34
|
}
|
|
35
|
+
async function listProviderModels(store, providerId) {
|
|
36
|
+
const provider = store.config.providers.find((item) => item.id === providerId);
|
|
37
|
+
if (!provider)
|
|
38
|
+
throw new Error(`unknown provider: ${providerId}`);
|
|
39
|
+
const key = provider.keys.find((item) => item.enabled !== false && store.states[`${provider.id}/${item.id}`]?.status !== 'unavailable');
|
|
40
|
+
if (!key)
|
|
41
|
+
throw new Error('provider has no available key');
|
|
42
|
+
const secret = store.vault.get(key.vaultRef);
|
|
43
|
+
if (!secret)
|
|
44
|
+
throw new Error('provider key secret is missing');
|
|
45
|
+
const headers = upstreamHeaders(provider.protocol, secret);
|
|
46
|
+
const response = await fetch(new URL('/v1/models', provider.baseUrl), { headers, signal: AbortSignal.timeout(10_000) });
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new Error(`model query failed with HTTP ${response.status}`);
|
|
49
|
+
const body = await response.json();
|
|
50
|
+
const values = Array.isArray(body?.data) ? body.data : Array.isArray(body?.models) ? body.models : [];
|
|
51
|
+
const ids = values.map((item) => typeof item === 'string' ? item : item?.id).filter((id) => typeof id === 'string' && id.length > 0);
|
|
52
|
+
return Array.from(new Set(ids)).sort();
|
|
53
|
+
}
|
|
26
54
|
function buildStatus(store) {
|
|
27
55
|
const token = store.vault.get(ACCESS_TOKEN_REF);
|
|
28
56
|
return {
|
|
@@ -39,6 +67,7 @@ function buildStatus(store) {
|
|
|
39
67
|
return {
|
|
40
68
|
id: key.id,
|
|
41
69
|
weight: key.weight,
|
|
70
|
+
...(key.enabled === false ? { enabled: false } : {}),
|
|
42
71
|
status: state?.status === 'unavailable' ? 'unavailable' : 'available',
|
|
43
72
|
lastError: state?.lastError,
|
|
44
73
|
secret: secret ? maskSecret(secret) : null,
|
|
@@ -46,7 +75,7 @@ function buildStatus(store) {
|
|
|
46
75
|
};
|
|
47
76
|
})
|
|
48
77
|
})),
|
|
49
|
-
routes: store.config.routes
|
|
78
|
+
routes: store.config.routes.map((route) => route.enabled === false ? { ...route, enabled: false } : { model: route.model, provider: route.provider })
|
|
50
79
|
};
|
|
51
80
|
}
|
|
52
81
|
async function route(req, res, store, sessionAuthorized = false) {
|
|
@@ -91,6 +120,8 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
91
120
|
return json(res, 200, { logs: logs.filter((entry) => (!model || entry.model === model) && (!key || entry.key === key) && (!status || String(entry.status) === status)) });
|
|
92
121
|
}
|
|
93
122
|
if (sub[0] === 'providers') {
|
|
123
|
+
if (sub.length === 3 && sub[2] === 'models' && method === 'GET')
|
|
124
|
+
return json(res, 200, { models: await listProviderModels(store, sub[1]) });
|
|
94
125
|
if (sub.length === 1 && method === 'POST') {
|
|
95
126
|
const body = await readJsonBody(req);
|
|
96
127
|
if (!body.id || !body.protocol || !body.baseUrl)
|
|
@@ -136,7 +167,11 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
136
167
|
return json(res, 200, { ok: true });
|
|
137
168
|
}
|
|
138
169
|
if (sub.length === 5 && sub[2] === 'keys' && sub[4] === 'enable' && method === 'POST') {
|
|
139
|
-
await store.mutate(() => enableKey(store, sub[1], sub[3]));
|
|
170
|
+
await store.mutate(async () => { await setKeyEnabled(store, sub[1], sub[3], true); await enableKey(store, sub[1], sub[3]); });
|
|
171
|
+
return json(res, 200, { ok: true });
|
|
172
|
+
}
|
|
173
|
+
if (sub.length === 5 && sub[2] === 'keys' && sub[4] === 'disable' && method === 'POST') {
|
|
174
|
+
await store.mutate(() => setKeyEnabled(store, sub[1], sub[3], false));
|
|
140
175
|
return json(res, 200, { ok: true });
|
|
141
176
|
}
|
|
142
177
|
if (sub.length === 5 && sub[2] === 'keys' && sub[4] === 'probe' && method === 'POST') {
|
|
@@ -149,7 +184,7 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
149
184
|
if (!secret || !model)
|
|
150
185
|
throw new Error('a key and route model are required for probing');
|
|
151
186
|
const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
|
|
152
|
-
const headers = provider.protocol
|
|
187
|
+
const headers = upstreamHeaders(provider.protocol, secret, true);
|
|
153
188
|
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 };
|
|
154
189
|
const response = await fetch(new URL(path, provider.baseUrl), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
|
|
155
190
|
if (response.ok)
|
|
@@ -167,14 +202,20 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
167
202
|
}
|
|
168
203
|
if (sub.length === 2 && method === 'PUT') {
|
|
169
204
|
const body = await readJsonBody(req);
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
205
|
+
if (body.provider !== undefined)
|
|
206
|
+
await store.mutate(() => updateRoute(store, decodeURIComponent(sub[1]), body.provider));
|
|
207
|
+
if (body.enabled !== undefined)
|
|
208
|
+
await store.mutate(() => setRouteEnabled(store, decodeURIComponent(sub[1]), body.enabled));
|
|
209
|
+
return json(res, 200, { ok: true });
|
|
210
|
+
}
|
|
211
|
+
if (sub.length === 2 && (sub[1] === 'enable' || sub[1] === 'disable') && method === 'POST') {
|
|
212
|
+
const model = url.searchParams.get('model');
|
|
213
|
+
if (!model)
|
|
214
|
+
throw new Error('model query parameter is required');
|
|
215
|
+
await store.mutate(() => setRouteEnabled(store, model, sub[1] === 'enable'));
|
|
173
216
|
return json(res, 200, { ok: true });
|
|
174
217
|
}
|
|
175
218
|
if (sub.length === 1 && method === 'DELETE') {
|
|
176
|
-
// The model goes in the query string: model names are not restricted to
|
|
177
|
-
// the provider/key id alphabet and may contain characters unsafe in a path segment.
|
|
178
219
|
const model = url.searchParams.get('model');
|
|
179
220
|
if (!model)
|
|
180
221
|
throw new Error('model query parameter is required');
|
package/dist/cli.js
CHANGED
|
@@ -27,7 +27,8 @@ Usage: khat <command> [arguments]
|
|
|
27
27
|
|
|
28
28
|
Setup
|
|
29
29
|
init first-time setup: create vault + access token + default config
|
|
30
|
-
start [--foreground]
|
|
30
|
+
start [--foreground] [--port <n>] start the proxy as a background daemon (or foreground with --foreground);
|
|
31
|
+
--port <n> changes the persisted listen port
|
|
31
32
|
stop stop the background daemon
|
|
32
33
|
status show providers, keys, routes and key health
|
|
33
34
|
doctor detect local Agent Tools and show connection guidance
|
|
@@ -62,6 +63,9 @@ Routes
|
|
|
62
63
|
route list
|
|
63
64
|
route remove <model>
|
|
64
65
|
|
|
66
|
+
Version
|
|
67
|
+
version | --version | -v print the khat version
|
|
68
|
+
|
|
65
69
|
Environment
|
|
66
70
|
KHAT_HOME data directory (default ~/.khat)
|
|
67
71
|
KHAT_ACCESS_TOKEN override the vault access token (mainly for tests)`;
|
|
@@ -103,6 +107,15 @@ function flagInt(flags, name, fallback) {
|
|
|
103
107
|
throw new Error(`--${name} must be a positive integer`);
|
|
104
108
|
return parsed;
|
|
105
109
|
}
|
|
110
|
+
function flagPort(flags, name) {
|
|
111
|
+
const value = flagString(flags, name);
|
|
112
|
+
if (value === undefined)
|
|
113
|
+
return undefined;
|
|
114
|
+
const parsed = Number(value);
|
|
115
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535)
|
|
116
|
+
throw new Error(`--${name} must be an integer between 1 and 65535`);
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
106
119
|
async function promptSecret(label) {
|
|
107
120
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
108
121
|
try {
|
|
@@ -244,10 +257,16 @@ async function runForeground() {
|
|
|
244
257
|
process.once('SIGINT', shutdown);
|
|
245
258
|
process.once('SIGTERM', shutdown);
|
|
246
259
|
}
|
|
247
|
-
async function runStart(foreground) {
|
|
260
|
+
async function runStart(foreground, port) {
|
|
261
|
+
const store = await openInitializedStore();
|
|
262
|
+
if (port !== undefined && port !== store.config.port) {
|
|
263
|
+
if (await daemonRunning())
|
|
264
|
+
throw new Error(`khat is already running on port ${store.config.port}; run 'khat stop' first, then start with --port ${port}`);
|
|
265
|
+
store.config.port = port;
|
|
266
|
+
await store.saveConfig();
|
|
267
|
+
}
|
|
248
268
|
if (foreground)
|
|
249
269
|
return runForeground();
|
|
250
|
-
const store = await openInitializedStore();
|
|
251
270
|
if (await daemonRunning())
|
|
252
271
|
throw new Error('khat is already running; use khat stop first');
|
|
253
272
|
const occupiedPid = await findListeningPid(store.config.port);
|
|
@@ -412,18 +431,23 @@ async function runImport(path, force) {
|
|
|
412
431
|
await importPortable(path, dataDir, createKeyProtector(), password, force);
|
|
413
432
|
console.log(`imported encrypted vault into ${dataDir}`);
|
|
414
433
|
}
|
|
415
|
-
async function
|
|
434
|
+
async function runVersion() {
|
|
435
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
436
|
+
console.log(pkg.version);
|
|
437
|
+
}
|
|
438
|
+
async function runInit(port) {
|
|
416
439
|
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
417
440
|
if (await fileExists(configPath)) {
|
|
418
441
|
console.error(`already initialized at ${configPath} (delete the directory to start over)`);
|
|
419
442
|
process.exitCode = 1;
|
|
420
443
|
return;
|
|
421
444
|
}
|
|
445
|
+
const config = port === undefined || port === defaultConfig.port ? defaultConfig : { ...defaultConfig, port };
|
|
422
446
|
const vault = await Vault.create(vaultPath, createKeyProtector());
|
|
423
447
|
const token = generateAccessToken();
|
|
424
448
|
vault.set(ACCESS_TOKEN_REF, token);
|
|
425
449
|
await vault.save();
|
|
426
|
-
await saveJsonAtomic(configPath,
|
|
450
|
+
await saveJsonAtomic(configPath, config);
|
|
427
451
|
await saveJsonAtomic(statePath, { keys: {} });
|
|
428
452
|
console.log(`Initialized ${dataDir}`);
|
|
429
453
|
console.log();
|
|
@@ -431,7 +455,7 @@ async function runInit() {
|
|
|
431
455
|
console.log(` ${token}`);
|
|
432
456
|
console.log();
|
|
433
457
|
console.log('Point your tools at the proxy, e.g.:');
|
|
434
|
-
console.log(` base URL: http://${
|
|
458
|
+
console.log(` base URL: http://${config.bind}:${config.port}/v1`);
|
|
435
459
|
console.log(` Authorization: Bearer ${token}`);
|
|
436
460
|
}
|
|
437
461
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -443,8 +467,13 @@ try {
|
|
|
443
467
|
case '-h':
|
|
444
468
|
console.log(USAGE);
|
|
445
469
|
break;
|
|
470
|
+
case 'version':
|
|
471
|
+
case '--version':
|
|
472
|
+
case '-v':
|
|
473
|
+
await runVersion();
|
|
474
|
+
break;
|
|
446
475
|
case 'init':
|
|
447
|
-
await runInit();
|
|
476
|
+
await runInit(flagPort(flags, 'port'));
|
|
448
477
|
break;
|
|
449
478
|
case 'export':
|
|
450
479
|
await runExport(positionals[0]);
|
|
@@ -453,7 +482,7 @@ try {
|
|
|
453
482
|
await runImport(positionals[0], flags.force === true);
|
|
454
483
|
break;
|
|
455
484
|
case 'start':
|
|
456
|
-
await runStart(flags.foreground === true);
|
|
485
|
+
await runStart(flags.foreground === true, flagPort(flags, 'port'));
|
|
457
486
|
break;
|
|
458
487
|
case 'stop':
|
|
459
488
|
await runStop();
|
package/dist/config.js
CHANGED
|
@@ -42,6 +42,9 @@ export function validateConfig(config) {
|
|
|
42
42
|
throw new Error(`Invalid timeouts.${field}: expected a positive integer`);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
|
|
46
|
+
throw new Error('Invalid config: port must be an integer between 1 and 65535');
|
|
47
|
+
}
|
|
45
48
|
for (const provider of config.providers) {
|
|
46
49
|
if (!provider.id || !provider.baseUrl || (provider.protocol !== 'openai' && provider.protocol !== 'anthropic') || !Array.isArray(provider.keys))
|
|
47
50
|
throw new Error(`Invalid provider: ${provider.id}`);
|
|
@@ -59,6 +62,8 @@ export function validateConfig(config) {
|
|
|
59
62
|
for (const key of provider.keys) {
|
|
60
63
|
if (!key.id || !Number.isInteger(key.weight) || key.weight < 1)
|
|
61
64
|
throw new Error(`Invalid key in provider ${provider.id}`);
|
|
65
|
+
if (key.enabled !== undefined && typeof key.enabled !== 'boolean')
|
|
66
|
+
throw new Error(`Invalid key ${provider.id}/${key.id}: enabled must be boolean`);
|
|
62
67
|
if (key.vaultRef !== `${provider.id}/${key.id}`)
|
|
63
68
|
throw new Error(`Invalid key ${provider.id}/${key.id}: vaultRef must match provider and key id`);
|
|
64
69
|
}
|
|
@@ -66,6 +71,8 @@ export function validateConfig(config) {
|
|
|
66
71
|
for (const route of config.routes) {
|
|
67
72
|
if (!route.model || !route.provider)
|
|
68
73
|
throw new Error('Invalid route: model and provider are required');
|
|
74
|
+
if (route.enabled !== undefined && typeof route.enabled !== 'boolean')
|
|
75
|
+
throw new Error(`Invalid route ${route.model}: enabled must be boolean`);
|
|
69
76
|
if (!config.providers.some((item) => item.id === route.provider))
|
|
70
77
|
throw new Error(`Invalid route ${route.model}: unknown provider ${route.provider}`);
|
|
71
78
|
}
|
package/dist/router.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
export function
|
|
1
|
+
export function findRoute(config, model) {
|
|
2
2
|
const slash = model.indexOf('/');
|
|
3
3
|
if (slash > 0) {
|
|
4
4
|
const providerId = model.slice(0, slash);
|
|
5
|
-
const
|
|
6
|
-
return
|
|
5
|
+
const routeModel = model.slice(slash + 1);
|
|
6
|
+
return config.routes.find((route) => route.provider === providerId && route.model === routeModel);
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
return config.routes.find((route) => route.model === model);
|
|
9
|
+
}
|
|
10
|
+
export function resolveProvider(config, model) {
|
|
11
|
+
const route = findRoute(config, model);
|
|
12
|
+
if (!route || route.enabled === false)
|
|
13
|
+
return undefined;
|
|
14
|
+
return config.providers.find((item) => item.id === route.provider);
|
|
15
|
+
}
|
|
16
|
+
export function isRouteDisabled(config, model) {
|
|
17
|
+
return findRoute(config, model)?.enabled === false;
|
|
10
18
|
}
|
package/dist/selector.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export class WeightedSelector {
|
|
2
2
|
current = new Map();
|
|
3
3
|
select(providerId, keys, states) {
|
|
4
|
-
const available = keys.filter((key) => states[`${providerId}/${key.id}`]?.status !== 'unavailable');
|
|
4
|
+
const available = keys.filter((key) => key.enabled !== false && states[`${providerId}/${key.id}`]?.status !== 'unavailable');
|
|
5
5
|
if (!available.length)
|
|
6
6
|
return undefined;
|
|
7
7
|
let total = 0;
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { watch } from 'node:fs';
|
|
|
3
3
|
import { readJsonFile, saveJsonAtomic, validateConfig, defaultTimeouts } from './config.js';
|
|
4
4
|
import { LogWriter } from './logger.js';
|
|
5
5
|
import { join, basename } from 'node:path';
|
|
6
|
-
import { resolveProvider } from './router.js';
|
|
6
|
+
import { 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';
|
|
@@ -102,8 +102,11 @@ export function createKhatServer(options) {
|
|
|
102
102
|
if (!parsed.model || typeof parsed.model !== 'string')
|
|
103
103
|
return json(res, 400, { error: { message: 'model is required' } });
|
|
104
104
|
const provider = resolveProvider(options.config, parsed.model);
|
|
105
|
-
if (!provider)
|
|
105
|
+
if (!provider) {
|
|
106
|
+
if (isRouteDisabled(options.config, parsed.model))
|
|
107
|
+
return json(res, 403, { error: { message: `Route disabled for model: ${parsed.model}` } });
|
|
106
108
|
return json(res, 404, { error: { message: `No route for model: ${parsed.model}` } });
|
|
109
|
+
}
|
|
107
110
|
if (provider.protocol !== protocol)
|
|
108
111
|
return json(res, 400, { error: { message: `Model ${parsed.model} resolves to a ${provider.protocol} provider, but ${req.url} speaks ${protocol}` } });
|
|
109
112
|
const tried = new Set();
|
|
@@ -182,13 +185,18 @@ export function createKhatServer(options) {
|
|
|
182
185
|
clearTimeout(idleTimer);
|
|
183
186
|
}
|
|
184
187
|
}
|
|
185
|
-
res.end();
|
|
186
|
-
res.off('close', clientGone);
|
|
187
188
|
const durationMs = Date.now() - startedAt;
|
|
188
189
|
const ttfbMs = (firstByteAt ?? Date.now()) - startedAt;
|
|
189
190
|
const keyRef = `${provider.id}/${key.id}`;
|
|
190
191
|
options.store?.recordCounter(keyRef, { requests: 1, failed: upstream.ok ? 0 : 1, bytesOut: bytes, tokensIn, tokensOut });
|
|
191
|
-
|
|
192
|
+
try {
|
|
193
|
+
await logger?.append({ ts: new Date().toISOString(), event: 'forward', model: parsed.model, provider: provider.id, key: keyRef, status: upstream.status, ttfbMs, durationMs, bytes, tokensIn, tokensOut });
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
console.error(`[khat] failed to write request log: ${error?.message ?? error}`);
|
|
197
|
+
}
|
|
198
|
+
res.end();
|
|
199
|
+
res.off('close', clientGone);
|
|
192
200
|
return;
|
|
193
201
|
}
|
|
194
202
|
}
|
package/dist/store.js
CHANGED
|
@@ -195,6 +195,27 @@ export async function removeKey(store, providerId, keyId) {
|
|
|
195
195
|
await store.vault.save();
|
|
196
196
|
await store.saveState();
|
|
197
197
|
}
|
|
198
|
+
export async function setKeyEnabled(store, providerId, keyId, enabled) {
|
|
199
|
+
const provider = findProvider(store, providerId);
|
|
200
|
+
const key = provider.keys.find((item) => item.id === keyId);
|
|
201
|
+
if (!key)
|
|
202
|
+
throw new Error(`unknown key: ${providerId}/${keyId}`);
|
|
203
|
+
const previous = key.enabled;
|
|
204
|
+
if (enabled)
|
|
205
|
+
delete key.enabled;
|
|
206
|
+
else
|
|
207
|
+
key.enabled = false;
|
|
208
|
+
try {
|
|
209
|
+
await persistConfig(store);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (previous === undefined)
|
|
213
|
+
delete key.enabled;
|
|
214
|
+
else
|
|
215
|
+
key.enabled = previous;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
198
219
|
export async function enableKey(store, providerId, keyId) {
|
|
199
220
|
const provider = findProvider(store, providerId);
|
|
200
221
|
if (!provider.keys.some((item) => item.id === keyId))
|
|
@@ -231,6 +252,26 @@ export async function updateRoute(store, model, providerId) {
|
|
|
231
252
|
throw error;
|
|
232
253
|
}
|
|
233
254
|
}
|
|
255
|
+
export async function setRouteEnabled(store, model, enabled) {
|
|
256
|
+
const route = store.config.routes.find((item) => item.model === model);
|
|
257
|
+
if (!route)
|
|
258
|
+
throw new Error(`unknown route: ${model}`);
|
|
259
|
+
const previous = route.enabled;
|
|
260
|
+
if (enabled)
|
|
261
|
+
delete route.enabled;
|
|
262
|
+
else
|
|
263
|
+
route.enabled = false;
|
|
264
|
+
try {
|
|
265
|
+
await persistConfig(store);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (previous === undefined)
|
|
269
|
+
delete route.enabled;
|
|
270
|
+
else
|
|
271
|
+
route.enabled = previous;
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
234
275
|
export async function removeRoute(store, model) {
|
|
235
276
|
const before = store.config.routes.length;
|
|
236
277
|
store.config.routes = store.config.routes.filter((route) => route.model !== model);
|
package/dist/tui-client.js
CHANGED
|
@@ -51,6 +51,9 @@ export class AdminClient {
|
|
|
51
51
|
async deleteKey(providerId, keyId) {
|
|
52
52
|
await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE' });
|
|
53
53
|
}
|
|
54
|
+
async disableKey(providerId, keyId) {
|
|
55
|
+
await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/disable`, { method: 'POST' });
|
|
56
|
+
}
|
|
54
57
|
async enableKey(providerId, keyId) {
|
|
55
58
|
await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/enable`, { method: 'POST' });
|
|
56
59
|
}
|
|
@@ -63,6 +66,8 @@ export class AdminClient {
|
|
|
63
66
|
async updateRoute(model, provider) {
|
|
64
67
|
await this.request(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: { provider } });
|
|
65
68
|
}
|
|
69
|
+
async enableRoute(model) { await this.request(`/_keys/routes/enable?model=${encodeURIComponent(model)}`, { method: 'POST' }); }
|
|
70
|
+
async disableRoute(model) { await this.request(`/_keys/routes/disable?model=${encodeURIComponent(model)}`, { method: 'POST' }); }
|
|
66
71
|
async deleteRoute(model) {
|
|
67
72
|
await this.request(`/_keys/routes?model=${encodeURIComponent(model)}`, { method: 'DELETE' });
|
|
68
73
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Pure state and helpers for the TUI: key-table ordering, log filtering and
|
|
2
|
+
// viewport slicing. No React, no I/O — directly unit-testable.
|
|
3
|
+
export const SECTIONS = ['overview', 'routes', 'logs', 'diagnostics'];
|
|
4
|
+
export const LOG_FILTERS = ['all', 'failed', 'ok'];
|
|
5
|
+
export function keyRows(status) {
|
|
6
|
+
const rows = [];
|
|
7
|
+
for (const provider of status?.providers ?? []) {
|
|
8
|
+
for (const key of provider.keys)
|
|
9
|
+
rows.push({ providerId: provider.id, keyId: key.id, key });
|
|
10
|
+
}
|
|
11
|
+
return rows.sort((a, b) => {
|
|
12
|
+
const rank = (row) => (row.key.status === 'unavailable' ? 0 : 1);
|
|
13
|
+
const byRank = rank(a) - rank(b);
|
|
14
|
+
if (byRank !== 0)
|
|
15
|
+
return byRank;
|
|
16
|
+
const byProvider = a.providerId.localeCompare(b.providerId);
|
|
17
|
+
if (byProvider !== 0)
|
|
18
|
+
return byProvider;
|
|
19
|
+
return a.keyId.localeCompare(b.keyId);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function providerOf(status, id) {
|
|
23
|
+
return status?.providers.find((item) => item.id === id);
|
|
24
|
+
}
|
|
25
|
+
/** Green = available, red = unavailable, yellow = available but failing often. */
|
|
26
|
+
export function keyTone(key) {
|
|
27
|
+
if (key.status === 'unavailable')
|
|
28
|
+
return 'red';
|
|
29
|
+
return key.counters.failed > 0 ? 'yellow' : 'green';
|
|
30
|
+
}
|
|
31
|
+
export function filterLogs(logs, filter) {
|
|
32
|
+
if (filter === 'failed')
|
|
33
|
+
return logs.filter((entry) => entry.status >= 400);
|
|
34
|
+
if (filter === 'ok')
|
|
35
|
+
return logs.filter((entry) => entry.status < 400);
|
|
36
|
+
return logs;
|
|
37
|
+
}
|
|
38
|
+
export function newestFirst(logs) {
|
|
39
|
+
return [...logs].reverse();
|
|
40
|
+
}
|
|
41
|
+
/** Sliding window centered on the cursor, clamped to the list bounds. */
|
|
42
|
+
export function viewport(length, cursor, height) {
|
|
43
|
+
if (height < 1 || length === 0)
|
|
44
|
+
return { start: 0, items: [], above: 0, below: 0 };
|
|
45
|
+
const size = Math.min(length, height);
|
|
46
|
+
const start = Math.min(Math.max(0, cursor - Math.floor(size / 2)), length - size);
|
|
47
|
+
const items = [];
|
|
48
|
+
for (let index = start; index < start + size; index++)
|
|
49
|
+
items.push(index);
|
|
50
|
+
return { start, items, above: start, below: length - (start + size) };
|
|
51
|
+
}
|
|
52
|
+
export function moveCursor(current, delta, length) {
|
|
53
|
+
if (length <= 0)
|
|
54
|
+
return 0;
|
|
55
|
+
return Math.min(length - 1, Math.max(0, current + delta));
|
|
56
|
+
}
|
|
57
|
+
export function clampCursor(current, length) {
|
|
58
|
+
return length <= 0 ? 0 : Math.min(current, length - 1);
|
|
59
|
+
}
|