@alilis/k-hat 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/admin.js CHANGED
@@ -5,6 +5,8 @@ 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
7
  import { findRoute, routeUpstreamModel } from './router.js';
8
+ import { keyBlocked } from './selector.js';
9
+ import { probeKey } from './probe.js';
8
10
  import { readExport, applyImport } from './portable-vault.js';
9
11
  // Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
10
12
  // only from loopback and behind the proxy access token, so a local non-loopback
@@ -39,7 +41,7 @@ async function listProviderModels(store, providerId) {
39
41
  const provider = store.config.providers.find((item) => item.id === providerId);
40
42
  if (!provider)
41
43
  throw new Error(`unknown provider: ${providerId}`);
42
- const key = provider.keys.find((item) => item.enabled !== false && store.states[`${provider.id}/${item.id}`]?.status !== 'unavailable');
44
+ const key = provider.keys.find((item) => item.enabled !== false && !keyBlocked(store.states[`${provider.id}/${item.id}`]));
43
45
  if (!key)
44
46
  throw new Error('provider has no available key');
45
47
  const secret = store.vault.get(key.vaultRef);
@@ -67,11 +69,14 @@ function buildStatus(store) {
67
69
  keys: provider.keys.map((key) => {
68
70
  const state = store.states[`${provider.id}/${key.id}`];
69
71
  const secret = store.vault.get(key.vaultRef);
72
+ // An expired cooldown is selectable again, so it must surface as available.
73
+ const status = keyBlocked(state) ? state.status : 'available';
70
74
  return {
71
75
  id: key.id,
72
76
  weight: key.weight,
73
77
  ...(key.enabled === false ? { enabled: false } : {}),
74
- status: state?.status === 'unavailable' ? 'unavailable' : 'available',
78
+ status,
79
+ ...(status === 'cooldown' ? { cooldownUntil: state.cooldownUntil } : {}),
75
80
  lastError: state?.lastError,
76
81
  secret: secret ? maskSecret(secret) : null,
77
82
  counters: store.counters[`${provider.id}/${key.id}`] ?? { requests: 0, failed: 0, bytesOut: 0, tokensIn: 0, tokensOut: 0 }
@@ -206,15 +211,10 @@ async function route(req, res, store, sessionAuthorized = false) {
206
211
  : store.config.routes.find((item) => item.provider === provider.id);
207
212
  if (!secret || !route || route.provider !== provider.id)
208
213
  throw new Error('a key and route model are required for probing');
209
- const model = routeUpstreamModel(route);
210
- const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
211
- const headers = upstreamHeaders(provider.protocol, secret, true);
212
- 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 };
213
- const response = await fetch(providerUrl(provider.baseUrl, path), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
214
- await response.arrayBuffer();
215
- if (response.ok)
214
+ const status = await probeKey(provider, routeUpstreamModel(route), secret);
215
+ if (status >= 200 && status < 300)
216
216
  await store.mutate(() => enableKey(store, provider.id, key.id));
217
- return json(res, response.ok ? 200 : 502, { ok: response.ok, status: response.status });
217
+ return json(res, status >= 200 && status < 300 ? 200 : 502, { ok: status >= 200 && status < 300, status });
218
218
  }
219
219
  }
220
220
  if (sub[0] === 'routes') {
package/dist/cli.js CHANGED
@@ -195,7 +195,14 @@ function keyStatusLine(store, providerId, keyId) {
195
195
  const state = store.states[`${providerId}/${keyId}`];
196
196
  if (!state || state.status === 'available')
197
197
  return { status: 'available', note: '' };
198
- return { status: 'unavailable', note: ` last error: ${state.lastError?.http ?? '?'} at ${state.lastError?.at ?? '?'}` };
198
+ const lastError = `last error: ${state.lastError?.http ?? '?'} at ${state.lastError?.at ?? '?'}`;
199
+ if (state.status === 'cooldown') {
200
+ const remaining = Date.parse(state.cooldownUntil ?? '') - Date.now();
201
+ if (remaining <= 0)
202
+ return { status: 'available', note: '' };
203
+ return { status: 'cooldown', note: ` cooling down, auto-retry in ${Math.ceil(remaining / 1000)}s (${lastError})` };
204
+ }
205
+ return { status: 'unavailable', note: ` ${lastError}` };
199
206
  }
200
207
  function printStatus(store) {
201
208
  const token = store.vault.get(ACCESS_TOKEN_REF);
@@ -402,7 +409,7 @@ async function runLog(flags) {
402
409
  return;
403
410
  }
404
411
  for (const entry of entries) {
405
- console.log(`${entry.ts}\t${entry.status}\t${entry.provider}/${entry.key.split('/').pop()}\t${entry.model}\t${entry.durationMs}ms\t${entry.tokensIn ?? 0}/${entry.tokensOut ?? 0} tokens`);
412
+ console.log(`${entry.ts}\t${entry.status}\t${entry.provider}/${entry.key.split('/').pop()}\t${entry.model ?? '(probe)'}\t${entry.durationMs ?? '—'}\t${entry.tokensIn ?? 0}/${entry.tokensOut ?? 0} tokens`);
406
413
  }
407
414
  }
408
415
  async function runDoctor() {
@@ -677,7 +684,7 @@ try {
677
684
  console.log(`${providerId}/${keyId} is available`);
678
685
  }
679
686
  else if ([401, 402, 429].includes(response.status))
680
- console.log('this error marks a key unavailable during proxying; check the key on the provider side');
687
+ console.log(response.status === 429 ? 'a 429 during proxying puts the key in cooldown; it revives automatically when the cooldown expires' : 'this error marks a key unavailable during proxying; check the key on the provider side');
681
688
  }
682
689
  else {
683
690
  console.error('usage: khat key add | list | update | remove | enable | test');
package/dist/config.js CHANGED
@@ -53,6 +53,9 @@ export function validateConfig(config) {
53
53
  if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
54
54
  throw new Error('Invalid config: port must be an integer between 1 and 65535');
55
55
  }
56
+ if (config.revision !== undefined && (!Number.isInteger(config.revision) || config.revision < 0)) {
57
+ throw new Error('Invalid config: revision must be a non-negative integer');
58
+ }
56
59
  for (const provider of config.providers) {
57
60
  if (!provider.id || !provider.baseUrl || (provider.protocol !== 'openai' && provider.protocol !== 'anthropic') || !Array.isArray(provider.keys))
58
61
  throw new Error(`Invalid provider: ${provider.id}`);
package/dist/probe.js ADDED
@@ -0,0 +1,15 @@
1
+ import { providerUrl } from './config.js';
2
+ const ANTHROPIC_VERSION = '2023-06-01';
3
+ /** Minimal one-token ping shared by the Admin probe endpoint and the background prober. */
4
+ export async function probeKey(provider, model, secret) {
5
+ const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
6
+ const headers = provider.protocol === 'anthropic'
7
+ ? { 'content-type': 'application/json', 'x-api-key': secret, 'anthropic-version': ANTHROPIC_VERSION }
8
+ : { 'content-type': 'application/json', authorization: `Bearer ${secret}` };
9
+ const body = provider.protocol === 'anthropic'
10
+ ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }
11
+ : { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }], stream: false };
12
+ const response = await fetch(providerUrl(provider.baseUrl, path), { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
13
+ await response.arrayBuffer();
14
+ return response.status;
15
+ }
package/dist/selector.js CHANGED
@@ -1,7 +1,19 @@
1
+ /** True when a key must not be selected: permanently unavailable, or still cooling down after a 429. */
2
+ export function keyBlocked(state, now = Date.now()) {
3
+ if (!state)
4
+ return false;
5
+ if (state.status === 'unavailable')
6
+ return true;
7
+ if (state.status === 'cooldown') {
8
+ const until = Date.parse(state.cooldownUntil ?? '');
9
+ return Number.isNaN(until) || until > now;
10
+ }
11
+ return false;
12
+ }
1
13
  export class WeightedSelector {
2
14
  current = new Map();
3
15
  select(providerId, keys, states) {
4
- const available = keys.filter((key) => key.enabled !== false && states[`${providerId}/${key.id}`]?.status !== 'unavailable');
16
+ const available = keys.filter((key) => key.enabled !== false && !keyBlocked(states[`${providerId}/${key.id}`]));
5
17
  if (!available.length)
6
18
  return undefined;
7
19
  let total = 0;
package/dist/server.js CHANGED
@@ -6,10 +6,21 @@ import { join, basename } from 'node:path';
6
6
  import { findRoute, routeUpstreamModel, resolveProvider, isRouteDisabled } from './router.js';
7
7
  import { WeightedSelector } from './selector.js';
8
8
  import { handleAdmin } from './admin.js';
9
+ import { enableKey } from './store.js';
10
+ import { probeKey } from './probe.js';
9
11
  import { ACCESS_TOKEN_REF } from './vault.js';
10
12
  const RETRYABLE = new Set([401, 402, 429]);
11
13
  const HOP_BY_HOP = new Set(['content-length', 'transfer-encoding', 'connection']);
12
14
  const ANTHROPIC_VERSION = '2023-06-01';
15
+ /** 429 cooldown: first wait, doubling per consecutive cycle, capped. */
16
+ const COOLDOWN_BASE_MS = 30_000;
17
+ const COOLDOWN_MAX_MS = 10 * 60_000;
18
+ /** An upstream Retry-After is honored, but never beyond this cap. */
19
+ const RETRY_AFTER_MAX_MS = 30 * 60_000;
20
+ /** Background probe cadence for permanently unavailable keys (cooldown keys revive on their own). */
21
+ const PROBE_INTERVAL_MS = 5 * 60_000;
22
+ /** Upper bound for buffering a non-streaming response to read its usage. */
23
+ const USAGE_BUFFER_LIMIT = 16 * 1024 * 1024;
13
24
  /** Entry paths accepted by the proxy, mapped to the protocol family they speak. */
14
25
  const ENDPOINTS = {
15
26
  '/v1/chat/completions': 'openai',
@@ -35,22 +46,27 @@ async function readBody(req, limit) {
35
46
  return Buffer.concat(chunks);
36
47
  }
37
48
  function scanUsage(protocol, text) {
49
+ // Usage snapshots are cumulative within a stream (Anthropic message_start/message_delta each
50
+ // carry a running total), so the per-field max across snapshots is the stream total; summing
51
+ // would double-count.
38
52
  let tokensIn = 0;
39
53
  let tokensOut = 0;
40
54
  for (const line of text.split('\n')) {
41
55
  if (!line.startsWith('data: '))
42
56
  continue;
43
57
  try {
44
- const usage = JSON.parse(line.slice(6)).usage;
58
+ const payload = JSON.parse(line.slice(6));
59
+ // Anthropic's message_start nests usage under `message`; message_delta and OpenAI carry it at the top level.
60
+ const usage = payload.usage ?? payload.message?.usage;
45
61
  if (!usage || typeof usage !== 'object')
46
62
  continue;
47
63
  if (protocol === 'openai') {
48
- tokensIn += Number(usage.prompt_tokens ?? usage.input_tokens ?? 0) || 0;
49
- tokensOut += Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0;
64
+ tokensIn = Math.max(tokensIn, Number(usage.prompt_tokens ?? usage.input_tokens ?? 0) || 0);
65
+ tokensOut = Math.max(tokensOut, Number(usage.completion_tokens ?? usage.output_tokens ?? 0) || 0);
50
66
  }
51
67
  else {
52
- tokensIn += Number(usage.input_tokens ?? 0) || 0;
53
- tokensOut += Number(usage.output_tokens ?? 0) || 0;
68
+ tokensIn = Math.max(tokensIn, Number(usage.input_tokens ?? 0) || 0);
69
+ tokensOut = Math.max(tokensOut, Number(usage.output_tokens ?? 0) || 0);
54
70
  }
55
71
  }
56
72
  catch { }
@@ -64,6 +80,18 @@ function upstreamAbort(abort, message) {
64
80
  reason.upstreamTimeout = true;
65
81
  abort.abort(reason);
66
82
  }
83
+ /** Parse a Retry-After header (delta-seconds or HTTP-date) into a capped cooldown duration. */
84
+ function retryAfterMs(value) {
85
+ if (!value)
86
+ return undefined;
87
+ const seconds = Number(value);
88
+ if (Number.isFinite(seconds) && seconds >= 0)
89
+ return Math.min(seconds * 1000, RETRY_AFTER_MAX_MS);
90
+ const date = Date.parse(value);
91
+ if (!Number.isNaN(date))
92
+ return Math.min(Math.max(date - Date.now(), 0), RETRY_AFTER_MAX_MS);
93
+ return undefined;
94
+ }
67
95
  export function createKhatServer(options) {
68
96
  const states = options.states ?? {};
69
97
  const secrets = { ...(options.secrets ?? {}) };
@@ -72,8 +100,26 @@ export function createKhatServer(options) {
72
100
  const statePath = options.statePath;
73
101
  const logger = options.store ? new LogWriter(join(options.store.dir, 'logs')) : undefined;
74
102
  const timeouts = () => ({ ...defaultTimeouts, ...options.config.timeouts });
75
- const markUnavailable = async (providerId, keyId, status) => {
76
- states[`${providerId}/${keyId}`] = { status: 'unavailable', lastError: { http: status, at: new Date().toISOString() } };
103
+ const markFailure = async (providerId, keyId, status, retryAfterHeader) => {
104
+ const ref = `${providerId}/${keyId}`;
105
+ const at = new Date().toISOString();
106
+ if (status === 429) {
107
+ // 429 is transient: cool down and auto-revive at cooldownUntil instead of dying permanently.
108
+ const previous = states[ref];
109
+ const count = previous?.status === 'cooldown' ? (previous.cooldownCount ?? 0) + 1 : 1;
110
+ const waitMs = retryAfterMs(retryAfterHeader) ?? Math.min(COOLDOWN_BASE_MS * 2 ** (count - 1), COOLDOWN_MAX_MS);
111
+ states[ref] = { status: 'cooldown', cooldownUntil: new Date(Date.now() + waitMs).toISOString(), cooldownCount: count, lastError: { http: status, at } };
112
+ }
113
+ else {
114
+ // 401/402 mean the key itself is bad; recovery requires a probe, manual enable, or the background prober.
115
+ states[ref] = { status: 'unavailable', lastError: { http: status, at } };
116
+ }
117
+ if (statePath)
118
+ await saveJsonAtomic(statePath, { keys: states, counters: options.store?.counters ?? {} });
119
+ };
120
+ const markHealthy = async (ref) => {
121
+ // A success clears cooldown residue and resets the backoff counter.
122
+ delete states[ref];
77
123
  if (statePath)
78
124
  await saveJsonAtomic(statePath, { keys: states, counters: options.store?.counters ?? {} });
79
125
  };
@@ -114,8 +160,16 @@ export function createKhatServer(options) {
114
160
  const tried = new Set();
115
161
  while (true) {
116
162
  const key = selector.select(provider.id, provider.keys.filter((item) => !tried.has(item.id)), states);
117
- if (!key)
118
- return json(res, 503, { error: { message: 'All keys are unavailable', keys: provider.keys.map((item) => ({ id: item.id, ...(states[`${provider.id}/${item.id}`] ?? { status: 'available' }) })) } });
163
+ if (!key) {
164
+ const now = Date.now();
165
+ const waits = provider.keys
166
+ .map((item) => states[`${provider.id}/${item.id}`])
167
+ .filter((state) => state?.status === 'cooldown' && Date.parse(state.cooldownUntil ?? '') > now)
168
+ .map((state) => Date.parse(state.cooldownUntil));
169
+ const earliest = waits.length ? Math.min(...waits) : undefined;
170
+ const hint = earliest !== undefined ? ` (${waits.length} cooling down, earliest retry in ~${Math.ceil((earliest - now) / 1000)}s)` : '';
171
+ return json(res, 503, { error: { message: `All keys are unavailable${hint}`, keys: provider.keys.map((item) => ({ id: item.id, ...(states[`${provider.id}/${item.id}`] ?? { status: 'available' }) })) } });
172
+ }
119
173
  tried.add(key.id);
120
174
  const secret = currentSecret(key.vaultRef);
121
175
  if (secret === undefined)
@@ -137,7 +191,7 @@ export function createKhatServer(options) {
137
191
  clearTimeout(headerTimer);
138
192
  }
139
193
  if (RETRYABLE.has(upstream.status)) {
140
- await markUnavailable(provider.id, key.id, upstream.status);
194
+ await markFailure(provider.id, key.id, upstream.status, upstream.headers.get('retry-after'));
141
195
  if (tried.size < provider.keys.length) {
142
196
  res.off('close', clientGone);
143
197
  continue;
@@ -155,28 +209,55 @@ export function createKhatServer(options) {
155
209
  res.writeHead(upstream.status, responseHeaders);
156
210
  if (upstream.body) {
157
211
  const idleTimer = setTimeout(() => upstreamAbort(abort, 'upstream stream idle timeout'), timeouts().streamIdleMs);
212
+ // Non-streaming JSON responses carry usage at the top level instead of in SSE lines;
213
+ // buffer them (bounded) so their tokens can be counted too.
214
+ const isEventStream = (upstream.headers.get('content-type') ?? '').toLowerCase().includes('text/event-stream');
215
+ let jsonBuffer = isEventStream ? undefined : [];
216
+ let jsonSize = 0;
158
217
  try {
159
218
  for await (const chunk of upstream.body) {
160
219
  const buffer = Buffer.from(chunk);
161
220
  if (firstByteAt === undefined)
162
221
  firstByteAt = Date.now();
163
222
  bytes += buffer.length;
164
- const combined = sseRemainder + buffer.toString('utf8');
165
- const lastNewline = combined.lastIndexOf('\n');
166
- if (lastNewline >= 0) {
167
- const usage = scanUsage(protocol, combined.slice(0, lastNewline + 1));
168
- tokensIn += usage.tokensIn;
169
- tokensOut += usage.tokensOut;
170
- sseRemainder = combined.slice(lastNewline + 1);
223
+ if (isEventStream) {
224
+ const combined = sseRemainder + buffer.toString('utf8');
225
+ const lastNewline = combined.lastIndexOf('\n');
226
+ if (lastNewline >= 0) {
227
+ const usage = scanUsage(protocol, combined.slice(0, lastNewline + 1));
228
+ tokensIn = Math.max(tokensIn, usage.tokensIn);
229
+ tokensOut = Math.max(tokensOut, usage.tokensOut);
230
+ sseRemainder = combined.slice(lastNewline + 1);
231
+ }
232
+ else
233
+ sseRemainder = combined;
234
+ }
235
+ if (jsonBuffer) {
236
+ jsonSize += buffer.length;
237
+ if (jsonSize > USAGE_BUFFER_LIMIT)
238
+ jsonBuffer = undefined;
239
+ else
240
+ jsonBuffer.push(buffer);
171
241
  }
172
- else
173
- sseRemainder = combined;
174
242
  res.write(buffer);
175
243
  idleTimer.refresh();
176
244
  }
177
- const usage = scanUsage(protocol, sseRemainder);
178
- tokensIn += usage.tokensIn;
179
- tokensOut += usage.tokensOut;
245
+ if (isEventStream) {
246
+ const usage = scanUsage(protocol, sseRemainder);
247
+ tokensIn = Math.max(tokensIn, usage.tokensIn);
248
+ tokensOut = Math.max(tokensOut, usage.tokensOut);
249
+ }
250
+ if (jsonBuffer) {
251
+ try {
252
+ const payload = JSON.parse(Buffer.concat(jsonBuffer).toString('utf8'));
253
+ const jsonUsage = payload?.usage;
254
+ if (jsonUsage && typeof jsonUsage === 'object') {
255
+ tokensIn = Math.max(tokensIn, Number(jsonUsage.prompt_tokens ?? jsonUsage.input_tokens ?? 0) || 0);
256
+ tokensOut = Math.max(tokensOut, Number(jsonUsage.completion_tokens ?? jsonUsage.output_tokens ?? 0) || 0);
257
+ }
258
+ }
259
+ catch { }
260
+ }
180
261
  }
181
262
  catch (error) {
182
263
  if (error?.upstreamTimeout)
@@ -190,6 +271,8 @@ export function createKhatServer(options) {
190
271
  const durationMs = Date.now() - startedAt;
191
272
  const ttfbMs = (firstByteAt ?? Date.now()) - startedAt;
192
273
  const keyRef = `${provider.id}/${key.id}`;
274
+ if (upstream.ok && states[keyRef])
275
+ await markHealthy(keyRef);
193
276
  options.store?.recordCounter(keyRef, { requests: 1, failed: upstream.ok ? 0 : 1, bytesOut: bytes, tokensIn, tokensOut });
194
277
  try {
195
278
  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 });
@@ -218,6 +301,19 @@ export function createKhatServer(options) {
218
301
  if (next === undefined)
219
302
  return;
220
303
  const validated = validateConfig(next);
304
+ // The daemon is the single writer (ADR-0005): a disk copy older than the in-memory config
305
+ // can only be a stale echo of our own write, never a genuine external edit.
306
+ const diskRevision = validated.revision;
307
+ const memoryRevision = options.config.revision;
308
+ if (diskRevision !== undefined && memoryRevision !== undefined && diskRevision < memoryRevision)
309
+ return;
310
+ // The socket keeps the address it was started with; adopting a new bind/port here would
311
+ // make status report an endpoint the process is not actually listening on.
312
+ if (validated.bind !== options.config.bind || validated.port !== options.config.port) {
313
+ console.error(`[khat] bind/port change requires a restart; still listening on ${options.config.bind}:${options.config.port}`);
314
+ validated.port = options.config.port;
315
+ validated.bind = options.config.bind;
316
+ }
221
317
  Object.assign(options.config, validated);
222
318
  for (const key of Object.keys(secrets))
223
319
  delete secrets[key];
@@ -233,6 +329,45 @@ export function createKhatServer(options) {
233
329
  }
234
330
  })();
235
331
  }) : undefined;
236
- server.once('close', () => configWatcher?.close());
332
+ // Cooldown keys revive on their own at cooldownUntil (probing them early would just re-429
333
+ // and extend the cooldown), so the prober only targets permanently unavailable keys.
334
+ const probeIntervalMs = options.probeIntervalMs ?? PROBE_INTERVAL_MS;
335
+ let probeInFlight = false;
336
+ const runProbeCycle = async () => {
337
+ if (probeInFlight)
338
+ return;
339
+ probeInFlight = true;
340
+ try {
341
+ for (const provider of options.config.providers) {
342
+ const route = options.config.routes.find((item) => item.provider === provider.id);
343
+ if (!route)
344
+ continue;
345
+ for (const target of provider.keys) {
346
+ if (states[`${provider.id}/${target.id}`]?.status !== 'unavailable')
347
+ continue;
348
+ try {
349
+ const secret = currentSecret(target.vaultRef);
350
+ if (secret === undefined)
351
+ continue;
352
+ const status = await probeKey(provider, routeUpstreamModel(route), secret);
353
+ if (status >= 200 && status < 300) {
354
+ await options.store.mutate(() => enableKey(options.store, provider.id, target.id));
355
+ await logger?.append({ ts: new Date().toISOString(), event: 'probe', provider: provider.id, key: `${provider.id}/${target.id}`, status, outcome: 'enabled' });
356
+ }
357
+ }
358
+ catch (error) {
359
+ console.error(`[khat] background probe failed for ${provider.id}/${target.id}: ${error?.message ?? error}`);
360
+ }
361
+ }
362
+ }
363
+ }
364
+ finally {
365
+ probeInFlight = false;
366
+ }
367
+ };
368
+ const prober = options.store && probeIntervalMs > 0 ? setInterval(() => { void runProbeCycle(); }, probeIntervalMs) : undefined;
369
+ prober?.unref();
370
+ server.once('close', () => { configWatcher?.close(); if (prober)
371
+ clearInterval(prober); });
237
372
  return server;
238
373
  }
package/dist/store.js CHANGED
@@ -43,7 +43,12 @@ export async function openStore(dir, protector) {
43
43
  release();
44
44
  }
45
45
  },
46
- saveConfig: () => saveJsonAtomic(configPath, config),
46
+ saveConfig: () => {
47
+ // Monotonic marker: lets the config watcher tell our own writes (and stale reads of them)
48
+ // apart from genuine external edits.
49
+ config.revision = Number.isInteger(config.revision) ? config.revision + 1 : 1;
50
+ return saveJsonAtomic(configPath, config);
51
+ },
47
52
  saveState: () => saveJsonAtomic(statePath, { keys: store.states, counters: store.counters }),
48
53
  recordCounter: (keyRef, delta) => {
49
54
  const current = store.counters[keyRef] ?? { requests: 0, failed: 0, bytesOut: 0, tokensIn: 0, tokensOut: 0 };
@@ -57,7 +62,9 @@ export async function openStore(dir, protector) {
57
62
  if (!stateTimer)
58
63
  stateTimer = setTimeout(() => {
59
64
  stateTimer = undefined;
60
- void store.saveState();
65
+ store.saveState().catch((error) => {
66
+ console.error(`[khat] failed to persist state: ${error?.message ?? error}`);
67
+ });
61
68
  }, 1_000);
62
69
  stateTimer.unref();
63
70
  }
package/dist/tui-state.js CHANGED
@@ -8,8 +8,8 @@ export function keyRows(status) {
8
8
  for (const key of provider.keys)
9
9
  rows.push({ providerId: provider.id, keyId: key.id, key });
10
10
  }
11
+ const rank = (row) => (row.key.status === 'unavailable' ? 0 : row.key.status === 'cooldown' ? 1 : 2);
11
12
  return rows.sort((a, b) => {
12
- const rank = (row) => (row.key.status === 'unavailable' ? 0 : 1);
13
13
  const byRank = rank(a) - rank(b);
14
14
  if (byRank !== 0)
15
15
  return byRank;
@@ -22,10 +22,12 @@ export function keyRows(status) {
22
22
  export function providerOf(status, id) {
23
23
  return status?.providers.find((item) => item.id === id);
24
24
  }
25
- /** Green = available, red = unavailable, yellow = available but failing often. */
25
+ /** Green = available, yellow = cooling down or failing often, red = unavailable. */
26
26
  export function keyTone(key) {
27
27
  if (key.status === 'unavailable')
28
28
  return 'red';
29
+ if (key.status === 'cooldown')
30
+ return 'yellow';
29
31
  return key.counters.failed > 0 ? 'yellow' : 'green';
30
32
  }
31
33
  export function filterLogs(logs, filter) {
package/dist/tui.js CHANGED
@@ -20,10 +20,11 @@ function Tabs({ section }) {
20
20
  function Edge({ text }) { return _jsx(Text, { dimColor: true, children: text }); }
21
21
  function KeyDetail({ row, status }) {
22
22
  const provider = providerOf(status, row.providerId);
23
- return _jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\u2500 ", row.providerId, "/", row.keyId, " (", provider?.protocol ?? '?', ") ", provider?.baseUrl ?? ''] }), _jsxs(Text, { children: ["\u72B6\u6001 ", _jsx(Dot, { tone: keyTone(row.key) }), " ", row.key.status === 'available' ? '可用' : '不可用', " \u00B7 \u6743\u91CD ", row.key.weight, " \u00B7 \u5BC6\u94A5 ", row.key.secret ?? '(缺失)'] }), _jsxs(Text, { children: ["\u8BF7\u6C42 ", n(row.key.counters.requests), " \u00B7 \u5931\u8D25 ", n(row.key.counters.failed), " \u00B7 \u5B57\u8282 ", bytes(row.key.counters.bytesOut), " \u00B7 tokens ", compact(row.key.counters.tokensIn), " / ", compact(row.key.counters.tokensOut)] }), _jsxs(Text, { children: ["\u6700\u8FD1\u9519\u8BEF ", row.key.lastError ? `HTTP ${row.key.lastError.http} · ${day(row.key.lastError.at)}` : '—'] })] });
23
+ const cooldownLeft = row.key.status === 'cooldown' ? Math.max(0, Math.ceil((Date.parse(row.key.cooldownUntil ?? '') - Date.now()) / 1000)) : undefined;
24
+ return _jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\u2500 ", row.providerId, "/", row.keyId, " (", provider?.protocol ?? '?', ") ", provider?.baseUrl ?? ''] }), _jsxs(Text, { children: ["\u72B6\u6001 ", _jsx(Dot, { tone: keyTone(row.key) }), " ", row.key.status === 'available' ? '可用' : row.key.status === 'cooldown' ? `冷却中 · 约 ${cooldownLeft ?? '?'}s 后自动恢复` : '不可用', " \u00B7 \u6743\u91CD ", row.key.weight, " \u00B7 \u5BC6\u94A5 ", row.key.secret ?? '(缺失)'] }), _jsxs(Text, { children: ["\u8BF7\u6C42 ", n(row.key.counters.requests), " \u00B7 \u5931\u8D25 ", n(row.key.counters.failed), " \u00B7 \u5B57\u8282 ", bytes(row.key.counters.bytesOut), " \u00B7 tokens ", compact(row.key.counters.tokensIn), " / ", compact(row.key.counters.tokensOut)] }), _jsxs(Text, { children: ["\u6700\u8FD1\u9519\u8BEF ", row.key.lastError ? `HTTP ${row.key.lastError.http} · ${day(row.key.lastError.at)}` : '—'] })] });
24
25
  }
25
26
  function LogDetail({ log }) {
26
- return _jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\u2500 ", day(log.ts), " \u00B7 ", log.model] }), _jsxs(Text, { children: ["provider ", log.provider, " \u00B7 key ", log.key, " \u00B7 HTTP ", log.status, log.ttfbMs !== undefined ? ` · ttfb ${duration(log.ttfbMs)}` : ''] }), _jsxs(Text, { children: ["\u8017\u65F6 ", log.durationMs !== undefined ? duration(log.durationMs) : '—', " \u00B7 \u5927\u5C0F ", log.bytes !== undefined ? bytes(log.bytes) : '—', " \u00B7 tokens ", log.tokensIn !== undefined ? n(log.tokensIn) : '—', " / ", log.tokensOut !== undefined ? n(log.tokensOut) : '—'] })] });
27
+ return _jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\u2500 ", day(log.ts), " \u00B7 ", log.model ?? '(probe)'] }), _jsxs(Text, { children: ["provider ", log.provider, " \u00B7 key ", log.key, " \u00B7 HTTP ", log.status, log.ttfbMs !== undefined ? ` · ttfb ${duration(log.ttfbMs)}` : ''] }), _jsxs(Text, { children: ["\u8017\u65F6 ", log.durationMs !== undefined ? duration(log.durationMs) : '—', " \u00B7 \u5927\u5C0F ", log.bytes !== undefined ? bytes(log.bytes) : '—', " \u00B7 tokens ", log.tokensIn !== undefined ? n(log.tokensIn) : '—', " / ", log.tokensOut !== undefined ? n(log.tokensOut) : '—'] })] });
27
28
  }
28
29
  function OverviewBody({ status, rows, cursor, detailOpen, height }) {
29
30
  const keys = rows.map((row) => row.key);
@@ -32,13 +33,14 @@ function OverviewBody({ status, rows, cursor, detailOpen, height }) {
32
33
  const tokensIn = keys.reduce((sum, item) => sum + item.counters.tokensIn, 0);
33
34
  const tokensOut = keys.reduce((sum, item) => sum + item.counters.tokensOut, 0);
34
35
  const unavailable = keys.filter((item) => item.status === 'unavailable').length;
36
+ const cooling = keys.filter((item) => item.status === 'cooldown').length;
35
37
  const tableHeight = Math.max(3, height - 5 - (detailOpen ? 5 : 0));
36
38
  const view = viewport(rows.length, cursor, tableHeight);
37
- return _jsxs(Box, { flexDirection: "column", gap: 1, children: [status ? _jsxs(Text, { children: ["daemon ", _jsx(Dot, { tone: "green" }), " \u5728\u7EBF ", status.listen, " \u00B7 keys ", keys.length, unavailable > 0 ? _jsxs(Text, { color: "red", children: ["\uFF08", unavailable, " \u4E0D\u53EF\u7528\uFF09"] }) : null, " \u00B7 \u8BF7\u6C42 ", compact(requests), " \u00B7 \u5931\u8D25 ", compact(failed), " \u00B7 tokens ", compact(tokensIn), "/", compact(tokensOut)] }) : _jsx(Text, { dimColor: true, children: "\u7B49\u5F85 daemon \u6570\u636E\u2026" }), rows.length === 0 ? _jsx(Text, { dimColor: true, children: "\u6682\u65E0 key \u00B7 \u7528 khat provider add / khat key add \u6DFB\u52A0" }) : _jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ` ${cell('PROVIDER', 14)}${cell('KEY', 14)}${'WT'.padStart(3)} · ${'REQ'.padStart(8)}${'FAIL'.padStart(8)}${'ERR'.padStart(9)}` }), view.above > 0 && _jsx(Edge, { text: ` ↑ ${view.above} more` }), view.items.map((index) => {
39
+ return _jsxs(Box, { flexDirection: "column", gap: 1, children: [status ? _jsxs(Text, { children: ["daemon ", _jsx(Dot, { tone: "green" }), " \u5728\u7EBF ", status.listen, " \u00B7 keys ", keys.length, unavailable > 0 ? _jsxs(Text, { color: "red", children: ["\uFF08", unavailable, " \u4E0D\u53EF\u7528\uFF09"] }) : null, cooling > 0 ? _jsxs(Text, { color: "yellow", children: ["\uFF08", cooling, " \u51B7\u5374\u4E2D\uFF09"] }) : null, " \u00B7 \u8BF7\u6C42 ", compact(requests), " \u00B7 \u5931\u8D25 ", compact(failed), " \u00B7 tokens ", compact(tokensIn), "/", compact(tokensOut)] }) : _jsx(Text, { dimColor: true, children: "\u7B49\u5F85 daemon \u6570\u636E\u2026" }), rows.length === 0 ? _jsx(Text, { dimColor: true, children: "\u6682\u65E0 key \u00B7 \u7528 khat provider add / khat key add \u6DFB\u52A0" }) : _jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ` ${cell('PROVIDER', 14)}${cell('KEY', 14)}${'WT'.padStart(3)} · ${'REQ'.padStart(8)}${'FAIL'.padStart(8)}${'ERR'.padStart(9)}` }), view.above > 0 && _jsx(Edge, { text: ` ↑ ${view.above} more` }), view.items.map((index) => {
38
40
  const row = rows[index];
39
41
  const selected = index === cursor;
40
42
  const tone = keyTone(row.key);
41
- return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? 'yellow' : row.key.status === 'unavailable' ? 'red' : undefined, children: `${selected ? '›' : ' '} ${cell(row.providerId, 14)}${cell(row.keyId, 14)} ${String(row.key.weight).padStart(2)} ` }), _jsx(Dot, { tone: tone }), _jsx(Text, { color: selected ? 'yellow' : row.key.status === 'unavailable' ? 'red' : undefined, children: ` ${numCell(row.key.counters.requests, 8)}${numCell(row.key.counters.failed, 8)}${(row.key.lastError ? `HTTP ${row.key.lastError.http}` : '—').padStart(9)}` })] }, `${row.providerId}/${row.keyId}`));
43
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? 'yellow' : row.key.status === 'unavailable' ? 'red' : row.key.status === 'cooldown' ? 'yellow' : undefined, children: `${selected ? '›' : ' '} ${cell(row.providerId, 14)}${cell(row.keyId, 14)} ${String(row.key.weight).padStart(2)} ` }), _jsx(Dot, { tone: tone }), _jsx(Text, { color: selected ? 'yellow' : row.key.status === 'unavailable' ? 'red' : row.key.status === 'cooldown' ? 'yellow' : undefined, children: ` ${numCell(row.key.counters.requests, 8)}${numCell(row.key.counters.failed, 8)}${(row.key.lastError ? `HTTP ${row.key.lastError.http}` : '—').padStart(9)}` })] }, `${row.providerId}/${row.keyId}`));
42
44
  }), view.below > 0 && _jsx(Edge, { text: ` ↓ ${view.below} more` }), detailOpen && rows[cursor] && _jsx(KeyDetail, { row: rows[cursor], status: status })] })] });
43
45
  }
44
46
  function RoutesBody({ status, cursor, height }) {
@@ -57,7 +59,7 @@ function LogsBody({ logs, filter, cursor, detailOpen, height }) {
57
59
  const log = rows[index];
58
60
  const selected = index === cursor;
59
61
  const failed = log.status >= 400;
60
- return (_jsx(Text, { color: selected ? 'yellow' : failed ? 'red' : undefined, children: `${selected ? '›' : ' '} ${cell(clock(log.ts), 8)} ${cell(String(log.status), 3)} ${cell(log.model, 26)}${cell(log.key, 14)}${(log.durationMs !== undefined ? duration(log.durationMs) : '—').padStart(8)}` }, `${log.ts}-${index}`));
62
+ return (_jsx(Text, { color: selected ? 'yellow' : failed ? 'red' : undefined, children: `${selected ? '›' : ' '} ${cell(clock(log.ts), 8)} ${cell(String(log.status), 3)} ${cell(log.model ?? '(probe)', 26)}${cell(log.key, 14)}${(log.durationMs !== undefined ? duration(log.durationMs) : '—').padStart(8)}` }, `${log.ts}-${index}`));
61
63
  }), view.below > 0 && _jsx(Edge, { text: ` ↓ ${view.below} more` }), detailOpen && rows[cursor] && _jsx(LogDetail, { log: rows[cursor] })] })] });
62
64
  }
63
65
  function DiagnosticsBody({ snapshot }) {
package/dist/web-ui.js CHANGED
@@ -92,6 +92,7 @@ tbody tr:hover td{background:var(--surface-2)}
92
92
  .pill{display:inline-flex;align-items:center;gap:5px;height:21px;padding:1px 9px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid;white-space:nowrap}
93
93
  .pill-status::before{content:'';width:6px;height:6px;border-radius:999px;background:currentColor;flex:none}
94
94
  .pill-ok{background:var(--ok-bg);color:var(--ok);border-color:var(--ok-border)}
95
+ .pill-warn{background:var(--warn-bg);color:var(--warn);border-color:var(--warn-border)}
95
96
  .pill-bad{background:var(--bad-bg);color:var(--bad);border-color:var(--bad-border)}
96
97
  .protocol-pill{background:var(--accent-soft);color:var(--accent);border-color:transparent}
97
98
  .legend{display:flex;align-items:center;gap:14px;font-size:12px;color:var(--muted);align-self:center}
@@ -118,7 +119,7 @@ tbody tr:hover td{background:var(--surface-2)}
118
119
  <div id="toasts" class="toasts"></div>
119
120
  <header class="topbar"><div class="container topbar-inner">
120
121
  <div class="brand"><span class="logo" aria-hidden="true"></span><span>khat</span><span id="connection" class="conn-badge"></span></div>
121
- <div class="topbar-actions"><span class="auto-note" data-i18n="autoRefresh">每 30 秒自动刷新</span><button id="langToggle" class="btn btn-secondary btn-icon" type="button" data-i18n-aria="toggleLang" title="中文 / English">EN</button><button id="themeToggle" class="btn btn-secondary btn-icon" type="button" data-i18n-aria="toggleTheme" title="切换深色 / 浅色模式">🌙</button><button id="refresh" class="btn btn-secondary" type="button" data-i18n="refresh">刷新</button></div>
122
+ <div class="topbar-actions"><span class="auto-note" data-i18n="autoRefresh">每 5 秒自动刷新</span><button id="langToggle" class="btn btn-secondary btn-icon" type="button" data-i18n-aria="toggleLang" title="中文 / English">EN</button><button id="themeToggle" class="btn btn-secondary btn-icon" type="button" data-i18n-aria="toggleTheme" title="切换深色 / 浅色模式">🌙</button><button id="refresh" class="btn btn-secondary" type="button" data-i18n="refresh">刷新</button></div>
122
123
  </div></header>
123
124
  <main class="container">
124
125
  <p id="error" class="alert alert-error"></p>
@@ -137,7 +138,7 @@ tbody tr:hover td{background:var(--surface-2)}
137
138
  <div class="card-head"><div><h2 class="card-title" data-i18n="cardProviders">Provider / Key 管理</h2><p class="card-sub" data-i18n="cardProvidersSub">维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。</p></div></div>
138
139
  <form id="providerForm" class="toolbar" autocomplete="off"><input name="id" placeholder="provider id" data-i18n-ph="phProviderId" required><input name="baseUrl" placeholder="https://api.example.com" data-i18n-ph="phBaseUrl" required><select name="protocol"><option>openai</option><option>anthropic</option></select><button class="btn btn-primary" data-i18n="btnAddProvider">添加 Provider</button></form>
139
140
  <form id="keyForm" class="toolbar" autocomplete="off"><select name="provider" required></select><input name="id" placeholder="key id" data-i18n-ph="phKeyId" required><input name="weight" type="number" min="1" step="1" value="1" data-i18n-title="weightTitle"><input name="value" type="password" placeholder="API key 明文(加密存入密钥库)" data-i18n-ph="phKeyValue" required autocomplete="new-password"><button class="btn btn-primary" data-i18n="btnAddKey">添加 Key</button></form>
140
- <div class="filter-bar"><input id="keySearch" type="search" placeholder="搜索 provider 名称 / baseUrl / key id / 错误原因…" data-i18n-ph="phSearchKey" data-i18n-aria="keyFilterAria" aria-label="筛选 Key"><select id="keyStatus" data-i18n-aria="statusFilterAria" aria-label="状态筛选"><option value="all" data-i18n="allStatus">全部状态</option><option value="available" data-i18n="onlyAvail">仅可用</option><option value="unavailable" data-i18n="onlyUnavail">仅不可用</option></select><span class="match-info" id="keyMatchInfo"></span></div>
141
+ <div class="filter-bar"><input id="keySearch" type="search" placeholder="搜索 provider 名称 / baseUrl / key id / 错误原因…" data-i18n-ph="phSearchKey" data-i18n-aria="keyFilterAria" aria-label="筛选 Key"><select id="keyStatus" data-i18n-aria="statusFilterAria" aria-label="状态筛选"><option value="all" data-i18n="allStatus">全部状态</option><option value="available" data-i18n="onlyAvail">仅可用</option><option value="cooldown" data-i18n="onlyCooldown">仅冷却中</option><option value="unavailable" data-i18n="onlyUnavail">仅不可用</option></select><span class="match-info" id="keyMatchInfo"></span></div>
141
142
  <div id="providers"></div>
142
143
  </section>
143
144
  <section class="card">
@@ -161,8 +162,8 @@ const NS='http://www.w3.org/2000/svg';
161
162
  const pad2=n=>String(n).padStart(2,'0');
162
163
  const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all',routeModels:[],routeSelected:new Set(),routeFilter:'',routeOpen:false,routeProvider:''};
163
164
  const I18N={
164
- zh:{title:'khat 密钥管理',toggleTheme:'切换深色 / 浅色模式',toggleLang:'切换语言',keyFilterAria:'筛选 Key',statusFilterAria:'状态筛选',resultFilterAria:'结果筛选',logCountAria:'日志条数',autoRefresh:'每 30 秒自动刷新',refresh:'刷新',statProviders:'Provider 数量',statProvidersSub:'已配置的服务商',statKeys:'Key 可用率',statReq:'近期请求成功率',statReqSub:'按最近一次拉取的日志统计',statTokens:'近期 Tokens 用量',statTokensSub:'输入 / 输出 合计',cardProviders:'Provider / Key 管理',cardProvidersSub:'维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。',phProviderId:'provider id',phName:'名称',phBaseUrl:'https://api.example.com',btnAddProvider:'添加 Provider',phKeyId:'key id',weightTitle:'轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比,默认 1',phKeyValue:'API key 明文(加密存入密钥库)',btnAddKey:'添加 Key',phSearchKey:'搜索 provider 名称 / baseUrl / key id / 错误原因…',allProviders:'全部 Provider',btnClearProvider:'显示全部 Provider',allStatus:'全部状态',onlyAvail:'仅可用',onlyUnavail:'仅不可用',cardRoutes:'Routes',cardRoutesSub:'模型到 Provider 的转发映射规则。',phModel:'model',btnAddRoute:'批量添加 Route',btnLoadModels:'获取模型',msEmpty:'请先获取模型',msLoading:'加载中…',msPlaceholder:'搜索 model…',msSelectAll:'全选',msSelected:'已选 {n} / {total}',msNoMatch:'无匹配模型',msNeedProvider:'请先选择 Provider',thModel:'Model',thProvider:'Provider',cardTraffic:'请求成功 / 失败趋势',cardTrafficSub:'将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。',legendOk:'成功',legendFail:'失败',chartEmpty:'暂无请求记录',cardLogs:'Logs',cardLogsSub:'最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。',phFilterLog:'按 model / key / provider / status 筛选…',allResult:'全部结果',onlyOk:'仅成功',onlyFail:'仅失败',logTail100:'最近 100 条',logTail300:'最近 300 条',logTail1000:'最近 1000 条',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'错误原因',thActions:'操作',avail:'可用',unavail:'不可用',disabled:'已停用',noKeysMatch:'没有符合筛选条件的 key',noKeysYet:'该 Provider 尚未添加 key,可在上方表单中添加',keyMatchInfo:'匹配 {k} / {t} 个 key · 显示 {p} 个 Provider',noProviderMatch:'没有匹配的 Provider 或 Key,试试调整关键字或状态筛选。',noRoutes:'暂无路由配置',cardImport:'导入加密备份',cardImportSub:'选择 khat export 导出的 .khat 文件并输入导出密码;导入会替换当前的 Provider、Route 与密钥配置。',phImportPassword:'导出密码',btnImport:'导入',confirmImport:'导入将替换当前的 Provider、Route 与密钥配置,且无法撤销。确定继续?',importFileAria:'选择导出文件',imported:'导入成功:{s} 个密钥 · {p} 个 Provider · {r} 条 Route',errChooseFile:'请先选择导出文件',noLogs:'暂无请求日志',noLogMatch:'没有匹配的日志,试试调整筛选条件。',poolWarn:'Provider {p} 没有可用的 Key,请恢复或探活后再重试。',btnDisable:'停用',btnRecover:'恢复',btnProbe:'探活',btnDelete:'删除',confirmDelKey:'删除 key {p}/{k}?',saved:'已保存',errOpenViaUi:'请通过 khat ui 打开此页面',errTicket:'票据无效或已过期',err401:'401 未授权(key 无效或被撤销)',err402:'402 余额不足',err429:'429 限速',errHttp:'HTTP {h} 错误',errOccurredAt:'发生于',trafficTip:'{t}:成功 {ok} 次 · 失败 {bad} 次',showLogs:'显示 {n} / {t} 条',statKeysSubTpl:'可用 {a} 个 · 共 {t} 个',statReqSubTpl:'成功 {ok} 次 · 失败 {fail} 次',keyCountTpl:'{n} 个 key',trafficAria:'请求成功 / 失败趋势'},
165
- en:{title:'khat Key Manager',toggleTheme:'Toggle dark / light theme',toggleLang:'Switch language',keyFilterAria:'Filter keys',statusFilterAria:'Filter by status',resultFilterAria:'Filter by result',logCountAria:'Log count',autoRefresh:'Auto-refresh every 30s',refresh:'Refresh',statProviders:'Providers',statProvidersSub:'Configured services',statKeys:'Key availability',statReq:'Recent success rate',statReqSub:'From last fetched logs',statTokens:'Recent tokens',statTokensSub:'Input / Output total',cardProviders:'Provider / Key Management',cardProvidersSub:'Manage providers, key pools and weights; filtering is local only.',phProviderId:'provider id',phName:'Name',phBaseUrl:'https://api.example.com',btnAddProvider:'Add Provider',phKeyId:'key id',weightTitle:'Polling weight: relative share among keys in this provider, default 1',phKeyValue:'API key plaintext (stored encrypted)',btnAddKey:'Add Key',phSearchKey:'Search provider / baseUrl / key id / error…',allProviders:'All providers',btnClearProvider:'Show all providers',allStatus:'All status',onlyAvail:'Available only',onlyUnavail:'Unavailable only',cardRoutes:'Routes',cardRoutesSub:'Model-to-Provider forwarding rules.',phModel:'model',btnAddRoute:'Batch add Routes',btnLoadModels:'Load models',msEmpty:'Load models first',msLoading:'Loading…',msPlaceholder:'Search model…',msSelectAll:'Select all',msSelected:'{n} / {total} selected',msNoMatch:'No matching models',msNeedProvider:'Select a provider first',thModel:'Model',thProvider:'Provider',cardTraffic:'Request success / failure trend',cardTrafficSub:'Aggregates recent logs into time buckets (max 30). Hover bars for detail.',legendOk:'Success',legendFail:'Failure',chartEmpty:'No request records',cardLogs:'Logs',cardLogsSub:'Recent forwarding logs; keyword & result filtering is local; changing count re-fetches.',phFilterLog:'Filter by model / key / provider / status…',allResult:'All results',onlyOk:'Success only',onlyFail:'Failure only',logTail100:'Last 100',logTail300:'Last 300',logTail1000:'Last 1000',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'Error reason',thActions:'Actions',avail:'Available',unavail:'Unavailable',disabled:'Disabled',noKeysMatch:'No keys match the filters',noKeysYet:'No keys yet for this provider; add via the form above',keyMatchInfo:'{k} / {t} keys matched · {p} providers shown',noProviderMatch:'No matching provider or key; adjust filters.',noRoutes:'No routes configured',cardImport:'Import encrypted backup',cardImportSub:'Pick a .khat file produced by khat export and enter its password; importing replaces the current providers, routes and keys.',phImportPassword:'Export password',btnImport:'Import',confirmImport:'Importing replaces the current providers, routes and keys. This cannot be undone. Continue?',importFileAria:'Choose export file',imported:'Imported: {s} secrets · {p} providers · {r} routes',errChooseFile:'Choose an export file first',noLogs:'No request logs',noLogMatch:'No matching logs; adjust filters.',poolWarn:'Provider {p} has no available keys. Recover or probe a key before retrying.',btnDisable:'Disable',btnRecover:'Enable',btnProbe:'Probe',btnDelete:'Delete',confirmDelKey:'Delete key {p}/{k}?',saved:'Saved',errOpenViaUi:'Please open this page via khat ui',errTicket:'Ticket invalid or expired',err401:'401 Unauthorized (key invalid or revoked)',err402:'402 Insufficient balance',err429:'429 Rate limited',errHttp:'HTTP {h} error',errOccurredAt:'at',trafficTip:'{t}: {ok} ok · {bad} failed',showLogs:'Showing {n} / {t}',statKeysSubTpl:'{a} available · {t} total',statReqSubTpl:'{ok} ok · {fail} failed',keyCountTpl:'{n} keys',trafficAria:'request success/failure trend'}
165
+ zh:{title:'khat 密钥管理',toggleTheme:'切换深色 / 浅色模式',toggleLang:'切换语言',keyFilterAria:'筛选 Key',statusFilterAria:'状态筛选',resultFilterAria:'结果筛选',logCountAria:'日志条数',autoRefresh:'每 5 秒自动刷新',refresh:'刷新',statProviders:'Provider 数量',statProvidersSub:'已配置的服务商',statKeys:'Key 可用率',statReq:'近期请求成功率',statReqSub:'按最近一次拉取的日志统计',statTokens:'近期 Tokens 用量',statTokensSub:'输入 / 输出 合计',cardProviders:'Provider / Key 管理',cardProvidersSub:'维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。',phProviderId:'provider id',phName:'名称',phBaseUrl:'https://api.example.com',btnAddProvider:'添加 Provider',phKeyId:'key id',weightTitle:'轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比,默认 1',phKeyValue:'API key 明文(加密存入密钥库)',btnAddKey:'添加 Key',phSearchKey:'搜索 provider 名称 / baseUrl / key id / 错误原因…',allProviders:'全部 Provider',btnClearProvider:'显示全部 Provider',allStatus:'全部状态',onlyAvail:'仅可用',onlyCooldown:'仅冷却中',onlyUnavail:'仅不可用',cardRoutes:'Routes',cardRoutesSub:'模型到 Provider 的转发映射规则。',phModel:'model',btnAddRoute:'批量添加 Route',btnLoadModels:'获取模型',msEmpty:'请先获取模型',msLoading:'加载中…',msPlaceholder:'搜索 model…',msSelectAll:'全选',msSelected:'已选 {n} / {total}',msNoMatch:'无匹配模型',msNeedProvider:'请先选择 Provider',thModel:'Model',thProvider:'Provider',cardTraffic:'请求成功 / 失败趋势',cardTrafficSub:'将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。',legendOk:'成功',legendFail:'失败',chartEmpty:'暂无请求记录',cardLogs:'Logs',cardLogsSub:'最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。',phFilterLog:'按 model / key / provider / status 筛选…',allResult:'全部结果',onlyOk:'仅成功',onlyFail:'仅失败',logTail100:'最近 100 条',logTail300:'最近 300 条',logTail1000:'最近 1000 条',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'错误原因',thActions:'操作',avail:'可用',cooldown:'冷却中',unavail:'不可用',disabled:'已停用',noKeysMatch:'没有符合筛选条件的 key',noKeysYet:'该 Provider 尚未添加 key,可在上方表单中添加',keyMatchInfo:'匹配 {k} / {t} 个 key · 显示 {p} 个 Provider',noProviderMatch:'没有匹配的 Provider 或 Key,试试调整关键字或状态筛选。',noRoutes:'暂无路由配置',cardImport:'导入加密备份',cardImportSub:'选择 khat export 导出的 .khat 文件并输入导出密码;导入会替换当前的 Provider、Route 与密钥配置。',phImportPassword:'导出密码',btnImport:'导入',confirmImport:'导入将替换当前的 Provider、Route 与密钥配置,且无法撤销。确定继续?',importFileAria:'选择导出文件',imported:'导入成功:{s} 个密钥 · {p} 个 Provider · {r} 条 Route',errChooseFile:'请先选择导出文件',noLogs:'暂无请求日志',noLogMatch:'没有匹配的日志,试试调整筛选条件。',poolWarn:'Provider {p} 没有可用的 Key,请恢复或探活后再重试。',btnDisable:'停用',btnRecover:'恢复',btnProbe:'探活',btnDelete:'删除',confirmDelKey:'删除 key {p}/{k}?',saved:'已保存',errOpenViaUi:'请通过 khat ui 打开此页面',errTicket:'票据无效或已过期',err401:'401 未授权(key 无效或被撤销)',err402:'402 余额不足',err429:'429 限速',errHttp:'HTTP {h} 错误',errOccurredAt:'发生于',trafficTip:'{t}:成功 {ok} 次 · 失败 {bad} 次',showLogs:'显示 {n} / {t} 条',statKeysSubTpl:'可用 {a} 个 · 共 {t} 个',statReqSubTpl:'成功 {ok} 次 · 失败 {fail} 次',keyCountTpl:'{n} 个 key',trafficAria:'请求成功 / 失败趋势'},
166
+ en:{title:'khat Key Manager',toggleTheme:'Toggle dark / light theme',toggleLang:'Switch language',keyFilterAria:'Filter keys',statusFilterAria:'Filter by status',resultFilterAria:'Filter by result',logCountAria:'Log count',autoRefresh:'Auto-refresh every 5s',refresh:'Refresh',statProviders:'Providers',statProvidersSub:'Configured services',statKeys:'Key availability',statReq:'Recent success rate',statReqSub:'From last fetched logs',statTokens:'Recent tokens',statTokensSub:'Input / Output total',cardProviders:'Provider / Key Management',cardProvidersSub:'Manage providers, key pools and weights; filtering is local only.',phProviderId:'provider id',phName:'Name',phBaseUrl:'https://api.example.com',btnAddProvider:'Add Provider',phKeyId:'key id',weightTitle:'Polling weight: relative share among keys in this provider, default 1',phKeyValue:'API key plaintext (stored encrypted)',btnAddKey:'Add Key',phSearchKey:'Search provider / baseUrl / key id / error…',allProviders:'All providers',btnClearProvider:'Show all providers',allStatus:'All status',onlyAvail:'Available only',onlyCooldown:'Cooling only',onlyUnavail:'Unavailable only',cardRoutes:'Routes',cardRoutesSub:'Model-to-Provider forwarding rules.',phModel:'model',btnAddRoute:'Batch add Routes',btnLoadModels:'Load models',msEmpty:'Load models first',msLoading:'Loading…',msPlaceholder:'Search model…',msSelectAll:'Select all',msSelected:'{n} / {total} selected',msNoMatch:'No matching models',msNeedProvider:'Select a provider first',thModel:'Model',thProvider:'Provider',cardTraffic:'Request success / failure trend',cardTrafficSub:'Aggregates recent logs into time buckets (max 30). Hover bars for detail.',legendOk:'Success',legendFail:'Failure',chartEmpty:'No request records',cardLogs:'Logs',cardLogsSub:'Recent forwarding logs; keyword & result filtering is local; changing count re-fetches.',phFilterLog:'Filter by model / key / provider / status…',allResult:'All results',onlyOk:'Success only',onlyFail:'Failure only',logTail100:'Last 100',logTail300:'Last 300',logTail1000:'Last 1000',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'Error reason',thActions:'Actions',avail:'Available',cooldown:'Cooling',unavail:'Unavailable',disabled:'Disabled',noKeysMatch:'No keys match the filters',noKeysYet:'No keys yet for this provider; add via the form above',keyMatchInfo:'{k} / {t} keys matched · {p} providers shown',noProviderMatch:'No matching provider or key; adjust filters.',noRoutes:'No routes configured',cardImport:'Import encrypted backup',cardImportSub:'Pick a .khat file produced by khat export and enter its password; importing replaces the current providers, routes and keys.',phImportPassword:'Export password',btnImport:'Import',confirmImport:'Importing replaces the current providers, routes and keys. This cannot be undone. Continue?',importFileAria:'Choose export file',imported:'Imported: {s} secrets · {p} providers · {r} routes',errChooseFile:'Choose an export file first',noLogs:'No request logs',noLogMatch:'No matching logs; adjust filters.',poolWarn:'Provider {p} has no available keys. Recover or probe a key before retrying.',btnDisable:'Disable',btnRecover:'Enable',btnProbe:'Probe',btnDelete:'Delete',confirmDelKey:'Delete key {p}/{k}?',saved:'Saved',errOpenViaUi:'Please open this page via khat ui',errTicket:'Ticket invalid or expired',err401:'401 Unauthorized (key invalid or revoked)',err402:'402 Insufficient balance',err429:'429 Rate limited',errHttp:'HTTP {h} error',errOccurredAt:'at',trafficTip:'{t}: {ok} ok · {bad} failed',showLogs:'Showing {n} / {t}',statKeysSubTpl:'{a} available · {t} total',statReqSubTpl:'{ok} ok · {fail} failed',keyCountTpl:'{n} keys',trafficAria:'request success/failure trend'}
166
167
  };
167
168
  let lang=(()=>{try{return localStorage.getItem('khatLang')==='en'?'en':'zh'}catch(e){return 'zh'}})();
168
169
  function t(key){return (I18N[lang]&&I18N[lang][key])||I18N.zh[key]||key}
@@ -178,7 +179,19 @@ function formatTs(ts){const d=new Date(ts);if(isNaN(d.getTime()))return String(t
178
179
  function errText(k){const http=k.lastError&&k.lastError.http;if(http===undefined)return '—';if(http===401)return t('err401');if(http===402)return t('err402');if(http===429)return t('err429');return tf('errHttp',{h:http})}
179
180
  function btn(label,className,onclick){const b=document.createElement('button');b.type='button';b.className=className;b.textContent=label;b.onclick=onclick;return b}
180
181
  function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message;toast(e.message)}}}
181
- function statusCell(ok,text,title){const td=document.createElement('td');const sp=document.createElement('span');sp.className='pill pill-status '+(ok?'pill-ok':'pill-bad');sp.textContent=text;if(title)sp.title=title;td.append(sp);return td}
182
+ function statusCell(kind,text,title){const td=document.createElement('td');const sp=document.createElement('span');sp.className='pill pill-status '+kind;sp.textContent=text;if(title)sp.title=title;td.append(sp);return td}
183
+ function cdUntilText(until){const s=Math.max(0,Math.ceil((Date.parse(until||'')-Date.now())/1000));return t('cooldown')+' · '+s+'s'}
184
+ function cdText(k){return cdUntilText(k.cooldownUntil)}
185
+ /** Route availability mirrors its provider's key pool: a route with no usable key cannot serve. */
186
+ function routePool(providerId){
187
+ const p=(state.status?state.status.providers:[]).find(x=>x.id===providerId);
188
+ const keys=(p?p.keys:[]).filter(k=>k.enabled!==false);
189
+ if(!keys.length)return{state:'unavail'};
190
+ if(keys.some(k=>k.status==='available'))return{state:'avail'};
191
+ const cooling=keys.filter(k=>k.status==='cooldown');
192
+ if(cooling.length)return{state:'cooldown',earliest:Math.min(...cooling.map(k=>Date.parse(k.cooldownUntil||'')))};
193
+ return{state:'unavail'};
194
+ }
182
195
  function syncProviderSelect(sel,providers,allowEmpty){const ids=providers.map(p=>p.id).join(',');if(sel.dataset.ids===ids&&(!allowEmpty||sel.dataset.allowEmpty==='1'))return;sel.dataset.ids=ids;if(allowEmpty)sel.dataset.allowEmpty='1';const prev=sel.value;const options=providers.map(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.id;return o});if(allowEmpty){const o=document.createElement('option');o.value='';o.textContent=t('allProviders');options.unshift(o)}sel.replaceChildren(...options);if(ids.split(',').includes(prev)||allowEmpty&&!prev)sel.value=prev}
183
196
  function renderConnection(){if(!state.status)return;const c=$('#connection');c.replaceChildren(Object.assign(document.createElement('i'),{className:'dot'}),document.createTextNode(' '+state.status.listen))}
184
197
  function renderStats(){const st=state.status;if(!st)return;const keys=[];for(const p of st.providers)for(const k of p.keys)keys.push(k);const avail=keys.filter(k=>k.enabled!==false&&k.status==='available').length;$('#statProviders').textContent=String(st.providers.length);$('#statKeys').textContent=keys.length?Math.round(avail*100/keys.length)+'%':'—';$('#statKeysSub').textContent=keys.length?tf('statKeysSubTpl',{a:avail,t:keys.length}):'—';let ok=0,fail=0,tIn=0,tOut=0;for(const x of state.logs){if(Number(x.status)<400)ok++;else fail++;tIn+=x.tokensIn||0;tOut+=x.tokensOut||0}const total=ok+fail;$('#statReq').textContent=total?Math.round(ok*100/total)+'%':'—';$('#statReqSub').textContent=tf('statReqSubTpl',{ok:ok,fail:fail});$('#statTokens').textContent=total?fmtNum(tIn)+' / '+fmtNum(tOut):'—'}
@@ -211,7 +224,7 @@ function renderProviders(){
211
224
  if(k.secret){const sk=document.createElement('span');sk.className='key-secret';sk.textContent=k.secret;kt.append(sk)}
212
225
  tr.append(kt);
213
226
  tr.append(cell(k.weight,'num'));
214
- tr.append(statusCell(k.enabled!==false&&k.status==='available',k.enabled===false?t('disabled'):(k.status==='available'?t('avail'):t('unavail')),k.lastError?errText(k)+(k.lastError.at?' · '+t('errOccurredAt')+' '+k.lastError.at:''):''));
227
+ tr.append(statusCell(k.enabled===false?'pill-bad':k.status==='available'?'pill-ok':k.status==='cooldown'?'pill-warn':'pill-bad',k.enabled===false?t('disabled'):(k.status==='available'?t('avail'):k.status==='cooldown'?cdText(k):t('unavail')),k.lastError?errText(k)+(k.lastError.at?' · '+t('errOccurredAt')+' '+k.lastError.at:''):''));
215
228
  tr.append(cell(k.counters.requests||0,'num'),cell(k.counters.failed||0,'num'),cell((k.counters.tokensIn||0)+' / '+(k.counters.tokensOut||0),'num'));
216
229
  const et=document.createElement('td');et.textContent=errText(k);et.className=k.lastError?'err-text':'err-text none';if(k.lastError)et.title=t('errOccurredAt')+' '+k.lastError.at;
217
230
  tr.append(et);
@@ -239,7 +252,13 @@ function renderRoutes(){
239
252
  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}
240
253
  for(const rt of rs){
241
254
  const tr=document.createElement('tr');
242
- 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')));
255
+ const pool=routePool(rt.provider);
256
+ let pillKind,pillText,pillTitle;
257
+ if(rt.enabled===false){pillKind='pill-bad';pillText=t('disabled')}
258
+ else if(pool.state==='avail'){pillKind='pill-ok';pillText=t('avail')}
259
+ else if(pool.state==='cooldown'){pillKind='pill-warn';pillText=cdUntilText(new Date(pool.earliest||Date.now()).toISOString())}
260
+ else{pillKind='pill-bad';pillText=t('unavail');pillTitle=tf('poolWarn',{p:rt.provider})}
261
+ tr.append(cell(rt.model,'mono-cell'),cell(rt.upstreamModel||rt.model,'mono-cell'),cell(rt.provider),statusCell(pillKind,pillText,pillTitle));
243
262
  const d=document.createElement('td');
244
263
  const toggle=rt.enabled!==false?'disable':'enable';
245
264
  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()})));
@@ -295,7 +314,7 @@ function renderLogs(){
295
314
  for(const e of rows){
296
315
  const r=document.createElement('tr');
297
316
  r.append(cell(formatTs(e.ts),'mono-cell'));
298
- r.append(statusCell(Number(e.status)<400,String(e.status)));
317
+ r.append(statusCell(Number(e.status)<400?'pill-ok':'pill-bad',String(e.status)));
299
318
  r.append(cell(e.provider,'mono-cell'),cell(e.key,'mono-cell'),cell(e.model,'mono-cell'));
300
319
  r.append(cell((e.durationMs==null?0:e.durationMs)+'ms','num'));
301
320
  r.append(cell((e.tokensIn||0)+' / '+(e.tokensOut||0),'num'));
@@ -399,13 +418,25 @@ $('#refresh').onclick=()=>load();
399
418
  window.addEventListener('resize',drawTraffic);
400
419
  load();setInterval(load,5000);
401
420
  </script></body></html>`;
421
+ /** Web UI sessions: sliding 1-hour expiry, so a closed tab does not leave an authorized session on the server forever. */
402
422
  export class UiSessions {
423
+ ttlMs;
403
424
  tickets = new Map();
404
- sessions = new Set();
425
+ sessions = new Map();
426
+ constructor(ttlMs = 3_600_000) {
427
+ this.ttlMs = ttlMs;
428
+ }
405
429
  createTicket() { const t = randomBytes(32).toString('base64url'); this.tickets.set(t, Date.now() + 60000); return t; }
406
430
  exchange(t) { const expires = this.tickets.get(t); this.tickets.delete(t); if (expires === undefined || expires < Date.now())
407
- return undefined; const s = randomBytes(32).toString('base64url'); this.sessions.add(s); return s; }
408
- valid(s) { return s !== undefined && this.sessions.has(s); }
431
+ return undefined; const now = Date.now(); for (const [s, e] of this.sessions)
432
+ if (e < now)
433
+ this.sessions.delete(s); const s = randomBytes(32).toString('base64url'); this.sessions.set(s, now + this.ttlMs); return s; }
434
+ valid(s) { if (s === undefined)
435
+ return false; const expires = this.sessions.get(s); if (expires === undefined)
436
+ return false; const now = Date.now(); if (expires < now) {
437
+ this.sessions.delete(s);
438
+ return false;
439
+ } this.sessions.set(s, now + this.ttlMs); return true; }
409
440
  }
410
441
  export function serveUi(req, res) { const path = new URL(req.url ?? '/', 'http://localhost').pathname; if (path !== '/_keys/ui' && path !== '/_keys/ui/')
411
442
  return false; res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(PAGE); return true; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alilis/k-hat",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -38,7 +38,8 @@
38
38
  "prepare": "npm run build",
39
39
  "start": "node dist/cli.js start",
40
40
  "khat": "node dist/cli.js",
41
- "test": "npm run build && node --test test/**/*.test.js"
41
+ "test": "npm run build && node --test test/**/*.test.js",
42
+ "bench": "node bench/bench.js"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/node": "^22.10.0",