@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 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` | 在交互式终端中查看 daemon 状态、Provider/Key、路由和请求日志 |
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 === 'anthropic' ? { 'content-type': 'application/json', 'x-api-key': secret, 'anthropic-version': '2023-06-01' } : { 'content-type': 'application/json', authorization: `Bearer ${secret}` };
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 (!body.provider || typeof body.provider !== 'string')
171
- throw new Error('provider is required');
172
- await store.mutate(() => updateRoute(store, decodeURIComponent(sub[1]), body.provider));
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] start the proxy as a background daemon (or foreground with --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 runInit() {
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, defaultConfig);
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://${defaultConfig.bind}:${defaultConfig.port}/v1`);
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 resolveProvider(config, model) {
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 provider = config.providers.find((item) => item.id === providerId);
6
- return provider && config.routes.some((route) => route.provider === providerId && route.model === model.slice(slash + 1)) ? provider : undefined;
5
+ const routeModel = model.slice(slash + 1);
6
+ return config.routes.find((route) => route.provider === providerId && route.model === routeModel);
7
7
  }
8
- const route = config.routes.find((item) => item.model === model);
9
- return route ? config.providers.find((item) => item.id === route.provider) : undefined;
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
- void logger?.append({ ts: new Date().toISOString(), event: 'forward', model: parsed.model, provider: provider.id, key: keyRef, status: upstream.status, ttfbMs, durationMs, bytes, tokensIn, tokensOut });
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);
@@ -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
+ }