@alilis/k-hat 0.2.5 → 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 +23 -10
- package/dist/cli.js +10 -3
- package/dist/config.js +5 -1
- package/dist/portable-vault.js +22 -1
- package/dist/probe.js +15 -0
- package/dist/selector.js +13 -1
- package/dist/server.js +158 -23
- package/dist/store.js +9 -2
- package/dist/tui-state.js +4 -2
- package/dist/tui.js +7 -5
- package/dist/web-ui.js +59 -19
- package/package.json +3 -2
package/dist/admin.js
CHANGED
|
@@ -5,6 +5,9 @@ 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';
|
|
10
|
+
import { readExport, applyImport } from './portable-vault.js';
|
|
8
11
|
// Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
|
|
9
12
|
// only from loopback and behind the proxy access token, so a local non-loopback
|
|
10
13
|
// client or a process without the token cannot mutate config/state/vault.
|
|
@@ -38,7 +41,7 @@ async function listProviderModels(store, providerId) {
|
|
|
38
41
|
const provider = store.config.providers.find((item) => item.id === providerId);
|
|
39
42
|
if (!provider)
|
|
40
43
|
throw new Error(`unknown provider: ${providerId}`);
|
|
41
|
-
const key = provider.keys.find((item) => item.enabled !== false && store.states[`${provider.id}/${item.id}`]
|
|
44
|
+
const key = provider.keys.find((item) => item.enabled !== false && !keyBlocked(store.states[`${provider.id}/${item.id}`]));
|
|
42
45
|
if (!key)
|
|
43
46
|
throw new Error('provider has no available key');
|
|
44
47
|
const secret = store.vault.get(key.vaultRef);
|
|
@@ -66,11 +69,14 @@ function buildStatus(store) {
|
|
|
66
69
|
keys: provider.keys.map((key) => {
|
|
67
70
|
const state = store.states[`${provider.id}/${key.id}`];
|
|
68
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';
|
|
69
74
|
return {
|
|
70
75
|
id: key.id,
|
|
71
76
|
weight: key.weight,
|
|
72
77
|
...(key.enabled === false ? { enabled: false } : {}),
|
|
73
|
-
status
|
|
78
|
+
status,
|
|
79
|
+
...(status === 'cooldown' ? { cooldownUntil: state.cooldownUntil } : {}),
|
|
74
80
|
lastError: state?.lastError,
|
|
75
81
|
secret: secret ? maskSecret(secret) : null,
|
|
76
82
|
counters: store.counters[`${provider.id}/${key.id}`] ?? { requests: 0, failed: 0, bytesOut: 0, tokensIn: 0, tokensOut: 0 }
|
|
@@ -118,6 +124,18 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
118
124
|
}
|
|
119
125
|
if (sub[0] === 'status' && method === 'GET')
|
|
120
126
|
return json(res, 200, buildStatus(store));
|
|
127
|
+
if (sub[0] === 'import' && sub.length === 1 && method === 'POST') {
|
|
128
|
+
const body = await readJsonBody(req);
|
|
129
|
+
if (typeof body.content !== 'string' || !body.content)
|
|
130
|
+
throw new Error('content (the export file text) is required');
|
|
131
|
+
if (typeof body.password !== 'string' || !body.password)
|
|
132
|
+
throw new Error('password is required');
|
|
133
|
+
if (body.content.length > 24 * 1024 * 1024)
|
|
134
|
+
throw new Error('export file is too large');
|
|
135
|
+
const payload = readExport(body.content, body.password);
|
|
136
|
+
const result = await store.mutate(() => applyImport(store, payload));
|
|
137
|
+
return json(res, 200, { ok: true, ...result });
|
|
138
|
+
}
|
|
121
139
|
if (sub[0] === 'logs' && method === 'GET') {
|
|
122
140
|
const tail = Number.parseInt(url.searchParams.get('tail') ?? '50', 10);
|
|
123
141
|
const logs = await new LogWriter(join(store.dir, 'logs')).recent(Number.isFinite(tail) ? tail : 50);
|
|
@@ -193,15 +211,10 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
193
211
|
: store.config.routes.find((item) => item.provider === provider.id);
|
|
194
212
|
if (!secret || !route || route.provider !== provider.id)
|
|
195
213
|
throw new Error('a key and route model are required for probing');
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
const headers = upstreamHeaders(provider.protocol, secret, true);
|
|
199
|
-
const body = provider.protocol === 'anthropic' ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] } : { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }], stream: false };
|
|
200
|
-
const response = await fetch(providerUrl(provider.baseUrl, path), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
|
|
201
|
-
await response.arrayBuffer();
|
|
202
|
-
if (response.ok)
|
|
214
|
+
const status = await probeKey(provider, routeUpstreamModel(route), secret);
|
|
215
|
+
if (status >= 200 && status < 300)
|
|
203
216
|
await store.mutate(() => enableKey(store, provider.id, key.id));
|
|
204
|
-
return json(res,
|
|
217
|
+
return json(res, status >= 200 && status < 300 ? 200 : 502, { ok: status >= 200 && status < 300, status });
|
|
205
218
|
}
|
|
206
219
|
}
|
|
207
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
|
-
|
|
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}
|
|
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
|
@@ -35,7 +35,8 @@ export function providerUrl(baseUrl, path) {
|
|
|
35
35
|
const base = new URL(baseUrl);
|
|
36
36
|
const prefix = base.pathname.replace(/\/+$/, '');
|
|
37
37
|
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
38
|
-
|
|
38
|
+
const overlap = prefix.endsWith('/v1') && (suffix === '/v1' || suffix.startsWith('/v1/')) ? '/v1'.length : 0;
|
|
39
|
+
base.pathname = `${prefix}${suffix.slice(overlap)}` || '/';
|
|
39
40
|
return base;
|
|
40
41
|
}
|
|
41
42
|
export function validateConfig(config) {
|
|
@@ -52,6 +53,9 @@ export function validateConfig(config) {
|
|
|
52
53
|
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
|
|
53
54
|
throw new Error('Invalid config: port must be an integer between 1 and 65535');
|
|
54
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
|
+
}
|
|
55
59
|
for (const provider of config.providers) {
|
|
56
60
|
if (!provider.id || !provider.baseUrl || (provider.protocol !== 'openai' && provider.protocol !== 'anthropic') || !Array.isArray(provider.keys))
|
|
57
61
|
throw new Error(`Invalid provider: ${provider.id}`);
|
package/dist/portable-vault.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
|
|
2
2
|
import { access, mkdir, readFile, rename, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { saveJsonAtomic, validateConfig } from './config.js';
|
|
4
|
+
import { saveJsonAtomic, validateConfig, defaultConfig } from './config.js';
|
|
5
5
|
import { Vault } from './vault.js';
|
|
6
6
|
const FORMAT = 'khat-vault-export';
|
|
7
7
|
const VERSION = 1;
|
|
@@ -144,3 +144,24 @@ export async function importPortable(path, targetDir, protector, password, force
|
|
|
144
144
|
await rm(stage, { recursive: true, force: true });
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
/** Apply a decrypted export payload to a live store: replaces secrets, config and per-key state in place, so references held by the running server stay valid. */
|
|
148
|
+
export async function applyImport(store, payload) {
|
|
149
|
+
validatePayload(payload);
|
|
150
|
+
const config = payload.config ?? structuredClone(defaultConfig);
|
|
151
|
+
// bind/port describe the socket the daemon is already listening on; the import must not claim a different one.
|
|
152
|
+
const listen = { bind: store.config.bind, port: store.config.port };
|
|
153
|
+
Object.assign(store.config, config, listen);
|
|
154
|
+
for (const ref of Object.keys(store.vault.secrets))
|
|
155
|
+
store.vault.delete(ref);
|
|
156
|
+
for (const [ref, value] of Object.entries(payload.secrets))
|
|
157
|
+
store.vault.set(ref, value);
|
|
158
|
+
for (const key of Object.keys(store.states))
|
|
159
|
+
delete store.states[key];
|
|
160
|
+
for (const key of Object.keys(store.counters))
|
|
161
|
+
delete store.counters[key];
|
|
162
|
+
// Vault first: the daemon's config watcher fires on config.json and then resolves secrets from the vault.
|
|
163
|
+
await store.vault.save();
|
|
164
|
+
await store.saveConfig();
|
|
165
|
+
await store.saveState();
|
|
166
|
+
return { secrets: Object.keys(payload.secrets).length, providers: config.providers.length, routes: config.routes.length };
|
|
167
|
+
}
|
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}`]
|
|
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
|
|
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
|
|
49
|
-
tokensOut
|
|
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
|
|
53
|
-
tokensOut
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,11 +119,15 @@ 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">每
|
|
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>
|
|
125
126
|
<p id="poolWarn" class="alert alert-warn"></p>
|
|
127
|
+
<section class="card">
|
|
128
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardImport">导入加密备份</h2><p class="card-sub" data-i18n="cardImportSub">选择 khat export 导出的 .khat 文件并输入导出密码;导入会替换当前的 Provider、Route 与密钥配置。</p></div></div>
|
|
129
|
+
<form id="importForm" class="toolbar" autocomplete="off"><input id="importFile" type="file" accept=".khat,.json,application/json" required data-i18n-aria="importFileAria" aria-label="选择导出文件"><input id="importPassword" type="password" placeholder="导出密码" data-i18n-ph="phImportPassword" autocomplete="new-password" required><button class="btn btn-primary" data-i18n="btnImport">导入</button></form>
|
|
130
|
+
</section>
|
|
126
131
|
<div class="stats">
|
|
127
132
|
<div class="stat"><div class="stat-label" data-i18n="statProviders">Provider 数量</div><div class="stat-value" id="statProviders">—</div><div class="stat-sub" data-i18n="statProvidersSub">已配置的服务商</div></div>
|
|
128
133
|
<div class="stat"><div class="stat-label" data-i18n="statKeys">Key 可用率</div><div class="stat-value" id="statKeys">—</div><div class="stat-sub" id="statKeysSub">—</div></div>
|
|
@@ -133,12 +138,12 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
133
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>
|
|
134
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>
|
|
135
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>
|
|
136
|
-
<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>
|
|
137
142
|
<div id="providers"></div>
|
|
138
143
|
</section>
|
|
139
144
|
<section class="card">
|
|
140
145
|
<div class="card-head"><div><h2 class="card-title" data-i18n="cardRoutes">Routes</h2><p class="card-sub" data-i18n="cardRoutesSub">模型到 Provider 的转发映射规则。</p></div></div>
|
|
141
|
-
<form id="routeForm" class="toolbar" autocomplete="off"><select name="provider"
|
|
146
|
+
<form id="routeForm" class="toolbar" autocomplete="off"><select name="provider"></select><button type="button" id="clearRouteProvider" class="btn btn-secondary" data-i18n="btnClearProvider">显示全部 Provider</button><div class="ms" id="routeModelsMs"><button type="button" class="ms-toggle ms-empty" aria-haspopup="listbox" aria-expanded="false">请先获取模型</button><div class="ms-panel" hidden><input class="ms-search" type="search" placeholder="搜索 model…" data-i18n-ph="msPlaceholder" autocomplete="off"><label class="ms-all"><input type="checkbox" class="ms-all-cb"><span data-i18n="msSelectAll">全选</span></label><div class="ms-list" role="listbox"></div></div></div><button type="button" id="routeModels" class="btn btn-secondary" data-i18n="btnLoadModels">获取模型</button><button class="btn btn-primary" data-i18n="btnAddRoute">批量添加 Route</button></form>
|
|
142
147
|
<div class="table-wrap"><table><thead><tr><th data-i18n="thModel">Model</th><th>Upstream Model</th><th data-i18n="thProvider">Provider</th><th data-i18n="thStatus">Status</th><th></th></tr></thead><tbody id="routes"></tbody></table></div>
|
|
143
148
|
</section>
|
|
144
149
|
<section class="card">
|
|
@@ -155,15 +160,15 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
155
160
|
const $=s=>document.querySelector(s), error=$('#error');
|
|
156
161
|
const NS='http://www.w3.org/2000/svg';
|
|
157
162
|
const pad2=n=>String(n).padStart(2,'0');
|
|
158
|
-
const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all',routeModels:[],routeSelected:new Set(),routeFilter:'',routeOpen:false};
|
|
163
|
+
const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all',routeModels:[],routeSelected:new Set(),routeFilter:'',routeOpen:false,routeProvider:''};
|
|
159
164
|
const I18N={
|
|
160
|
-
zh:{title:'khat 密钥管理',toggleTheme:'切换深色 / 浅色模式',toggleLang:'切换语言',keyFilterAria:'筛选 Key',statusFilterAria:'状态筛选',resultFilterAria:'结果筛选',logCountAria:'日志条数',autoRefresh:'每
|
|
161
|
-
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
|
|
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'}
|
|
162
167
|
};
|
|
163
168
|
let lang=(()=>{try{return localStorage.getItem('khatLang')==='en'?'en':'zh'}catch(e){return 'zh'}})();
|
|
164
169
|
function t(key){return (I18N[lang]&&I18N[lang][key])||I18N.zh[key]||key}
|
|
165
170
|
function tf(key,vars){let s=t(key);if(vars)for(const k in vars)s=s.split('{'+k+'}').join(String(vars[k]));return s}
|
|
166
|
-
function applyLang(l){lang=l;try{localStorage.setItem('khatLang',l)}catch(e){}document.documentElement.lang=l==='en'?'en':'zh-CN';document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n)});document.querySelectorAll('[data-i18n-ph]').forEach(el=>{el.placeholder=t(el.dataset.i18nPh)});document.querySelectorAll('[data-i18n-title]').forEach(el=>{el.title=t(el.dataset.i18nTitle)});document.querySelectorAll('[data-i18n-aria]').forEach(el=>{el.setAttribute('aria-label',t(el.dataset.i18nAria))});renderConnection();renderStats();renderProviders();renderRoutes();renderRouteModels();renderLogs();drawTraffic();const b=$('#langToggle');if(b)b.textContent=l==='zh'?'EN':'中'}
|
|
171
|
+
function applyLang(l){lang=l;try{localStorage.setItem('khatLang',l)}catch(e){}document.documentElement.lang=l==='en'?'en':'zh-CN';document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n)});document.querySelectorAll('[data-i18n-ph]').forEach(el=>{el.placeholder=t(el.dataset.i18nPh)});document.querySelectorAll('[data-i18n-title]').forEach(el=>{el.title=t(el.dataset.i18nTitle)});document.querySelectorAll('[data-i18n-aria]').forEach(el=>{el.setAttribute('aria-label',t(el.dataset.i18nAria))});const allProvider=$('#routeForm [name=provider] option[value=""]');if(allProvider)allProvider.textContent=t('allProviders');renderConnection();renderStats();renderProviders();renderRoutes();renderRouteModels();renderLogs();drawTraffic();const b=$('#langToggle');if(b)b.textContent=l==='zh'?'EN':'中'}
|
|
167
172
|
function toast(msg,type,ms){type=type||'error';ms=ms||3500;const c=$('#toasts');if(!c)return;const el=document.createElement('div');el.className='toast toast-'+type;el.textContent=msg;c.append(el);setTimeout(()=>{el.classList.add('fade');setTimeout(()=>el.remove(),300)},ms)}
|
|
168
173
|
async function api(path,init){init=init||{};const r=await fetch(path,{...init,headers:{authorization:'Bearer '+sessionStorage.khatSession,'content-type':'application/json',...(init.headers||{})}});const body=await r.json().catch(()=>({}));if(!r.ok)throw Error(body.error?.message||r.statusText);return body}
|
|
169
174
|
async function exchange(){const ticket=new URLSearchParams(location.search).get('ticket');if(sessionStorage.khatSession){if(ticket)window.history.replaceState({},'', '/_keys/ui');return}if(ticket){const r=await fetch('/_keys/ticket/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ticket})});if(!r.ok)throw Error(t('errTicket'));sessionStorage.khatSession=(await r.json()).session;window.history.replaceState({},'', '/_keys/ui')}if(!sessionStorage.khatSession)throw Error(t('errOpenViaUi'))}
|
|
@@ -174,8 +179,20 @@ function formatTs(ts){const d=new Date(ts);if(isNaN(d.getTime()))return String(t
|
|
|
174
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})}
|
|
175
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}
|
|
176
181
|
function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message;toast(e.message)}}}
|
|
177
|
-
function statusCell(
|
|
178
|
-
function
|
|
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
|
+
}
|
|
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}
|
|
179
196
|
function renderConnection(){if(!state.status)return;const c=$('#connection');c.replaceChildren(Object.assign(document.createElement('i'),{className:'dot'}),document.createTextNode(' '+state.status.listen))}
|
|
180
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):'—'}
|
|
181
198
|
function providerHead(p,keys){const head=document.createElement('div');head.className='provider-head';const nm=document.createElement('h3');nm.className='provider-name';nm.textContent=p.name||p.id;nm.title=p.id;head.append(nm);const pp=document.createElement('span');pp.className='pill protocol-pill';pp.textContent=p.protocol;head.append(pp);const url=document.createElement('span');url.className='provider-url';url.textContent=p.baseUrl||'';url.title=p.baseUrl||'';head.append(url);const cnt=document.createElement('span');cnt.className='provider-count';cnt.textContent=tf('keyCountTpl',{n:keys.length+(keys.length!==p.keys.length?(' / '+p.keys.length):'')});head.append(cnt);return head}
|
|
@@ -207,7 +224,7 @@ function renderProviders(){
|
|
|
207
224
|
if(k.secret){const sk=document.createElement('span');sk.className='key-secret';sk.textContent=k.secret;kt.append(sk)}
|
|
208
225
|
tr.append(kt);
|
|
209
226
|
tr.append(cell(k.weight,'num'));
|
|
210
|
-
tr.append(statusCell(k.enabled
|
|
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:''):''));
|
|
211
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'));
|
|
212
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;
|
|
213
230
|
tr.append(et);
|
|
@@ -231,11 +248,17 @@ function renderProviders(){
|
|
|
231
248
|
}
|
|
232
249
|
function renderRoutes(){
|
|
233
250
|
const tb=$('#routes');tb.replaceChildren();
|
|
234
|
-
const rs=state.status?state.status.routes:[];
|
|
251
|
+
const rs=(state.status?state.status.routes:[]).filter(rt=>!state.routeProvider||rt.provider===state.routeProvider);
|
|
235
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}
|
|
236
253
|
for(const rt of rs){
|
|
237
254
|
const tr=document.createElement('tr');
|
|
238
|
-
|
|
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));
|
|
239
262
|
const d=document.createElement('td');
|
|
240
263
|
const toggle=rt.enabled!==false?'disable':'enable';
|
|
241
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()})));
|
|
@@ -291,7 +314,7 @@ function renderLogs(){
|
|
|
291
314
|
for(const e of rows){
|
|
292
315
|
const r=document.createElement('tr');
|
|
293
316
|
r.append(cell(formatTs(e.ts),'mono-cell'));
|
|
294
|
-
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)));
|
|
295
318
|
r.append(cell(e.provider,'mono-cell'),cell(e.key,'mono-cell'),cell(e.model,'mono-cell'));
|
|
296
319
|
r.append(cell((e.durationMs==null?0:e.durationMs)+'ms','num'));
|
|
297
320
|
r.append(cell((e.tokensIn||0)+' / '+(e.tokensOut||0),'num'));
|
|
@@ -352,7 +375,10 @@ async function load(){
|
|
|
352
375
|
state.status=statusResultValue.value;
|
|
353
376
|
renderConnection();
|
|
354
377
|
syncProviderSelect($('#keyForm [name=provider]'),state.status.providers);
|
|
355
|
-
syncProviderSelect($('#routeForm [name=provider]'),state.status.providers);
|
|
378
|
+
syncProviderSelect($('#routeForm [name=provider]'),state.status.providers,true);
|
|
379
|
+
const routeProvider=$('#routeForm [name=provider]');
|
|
380
|
+
if(state.routeProvider&&state.status.providers.some(p=>p.id===state.routeProvider))routeProvider.value=state.routeProvider;
|
|
381
|
+
else {state.routeProvider='';routeProvider.value='';}
|
|
356
382
|
renderProviders();
|
|
357
383
|
renderRoutes();
|
|
358
384
|
}else{error.textContent=statusResultValue.reason?.message||String(statusResultValue.reason);toast(error.textContent)}
|
|
@@ -372,7 +398,8 @@ $('#langToggle').onclick=()=>applyLang(lang==='zh'?'en':'zh');
|
|
|
372
398
|
applyTheme(document.documentElement.getAttribute('data-theme')||'light');
|
|
373
399
|
applyLang(lang);
|
|
374
400
|
$('#routeModels').onclick=()=>loadModels();
|
|
375
|
-
$('#routeForm [name=provider]').onchange=clearRouteModels;
|
|
401
|
+
$('#routeForm [name=provider]').onchange=e=>{state.routeProvider=e.target.value;clearRouteModels();renderRoutes()};
|
|
402
|
+
$('#clearRouteProvider').onclick=()=>{state.routeProvider='';$('#routeForm [name=provider]').value='';clearRouteModels();renderRoutes()};
|
|
376
403
|
$('#routeModelsMs .ms-toggle').onclick=e=>{e.preventDefault();if(!state.routeModels.length)return;openRouteModels(!state.routeOpen)};
|
|
377
404
|
$('#routeModelsMs .ms-search').oninput=e=>{state.routeFilter=e.target.value;buildRouteModelList();syncRouteModelHeader()};
|
|
378
405
|
$('#routeModelsMs .ms-all-cb').onchange=e=>{const kw=state.routeFilter.trim().toLowerCase();const items=state.routeModels.filter(m=>!kw||m.toLowerCase().includes(kw));if(e.target.checked)items.forEach(m=>state.routeSelected.add(m));else items.forEach(m=>state.routeSelected.delete(m));buildRouteModelList();syncRouteModelHeader()};
|
|
@@ -380,7 +407,8 @@ document.addEventListener('click',e=>{if(!state.routeOpen)return;const ms=$('#ro
|
|
|
380
407
|
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&state.routeOpen){openRouteModels(false);const tg=$('#routeModelsMs .ms-toggle');if(tg)tg.focus()}});
|
|
381
408
|
$('#providerForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers',{method:'POST',body:JSON.stringify(x)});e.target.reset();toast(t('saved'),'ok');load()});
|
|
382
409
|
$('#keyForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers/'+encodeURIComponent(x.provider)+'/keys',{method:'POST',body:JSON.stringify({id:x.id,value:x.value,weight:Number(x.weight)||1})});e.target.reset();toast(t('saved'),'ok');load()});
|
|
383
|
-
$('#routeForm').onsubmit=run(async e=>{e.preventDefault();const provider=e.target.provider.value;const models=[...state.routeSelected];if(!models.length){toast(t('msEmpty'));return}for(const model of models)await api('/_keys/routes',{method:'POST',body:JSON.stringify({model,provider})});clearRouteModels();e.target.reset();toast(t('saved'),'ok');load()});
|
|
410
|
+
$('#routeForm').onsubmit=run(async e=>{e.preventDefault();const provider=e.target.provider.value;const models=[...state.routeSelected];if(!provider){toast(t('msNeedProvider'));return}if(!models.length){toast(t('msEmpty'));return}for(const model of models)await api('/_keys/routes',{method:'POST',body:JSON.stringify({model,provider})});clearRouteModels();e.target.reset();toast(t('saved'),'ok');load()});
|
|
411
|
+
$('#importForm').onsubmit=run(async e=>{e.preventDefault();const file=$('#importFile').files[0];if(!file){toast(t('errChooseFile'));return}if(!confirm(t('confirmImport')))return;const content=await file.text();const r=await api('/_keys/import',{method:'POST',body:JSON.stringify({content,password:$('#importPassword').value})});e.target.reset();toast(tf('imported',{s:r.secrets,p:r.providers,r:r.routes}),'ok',6000);load()});
|
|
384
412
|
$('#keySearch').oninput=e=>{state.keyKeyword=e.target.value;renderProviders()};
|
|
385
413
|
$('#keyStatus').onchange=e=>{state.keyStatus=e.target.value;renderProviders()};
|
|
386
414
|
$('#logFilter').oninput=e=>{state.logKeyword=e.target.value;renderLogs()};
|
|
@@ -390,13 +418,25 @@ $('#refresh').onclick=()=>load();
|
|
|
390
418
|
window.addEventListener('resize',drawTraffic);
|
|
391
419
|
load();setInterval(load,5000);
|
|
392
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. */
|
|
393
422
|
export class UiSessions {
|
|
423
|
+
ttlMs;
|
|
394
424
|
tickets = new Map();
|
|
395
|
-
sessions = new
|
|
425
|
+
sessions = new Map();
|
|
426
|
+
constructor(ttlMs = 3_600_000) {
|
|
427
|
+
this.ttlMs = ttlMs;
|
|
428
|
+
}
|
|
396
429
|
createTicket() { const t = randomBytes(32).toString('base64url'); this.tickets.set(t, Date.now() + 60000); return t; }
|
|
397
430
|
exchange(t) { const expires = this.tickets.get(t); this.tickets.delete(t); if (expires === undefined || expires < Date.now())
|
|
398
|
-
return undefined; const
|
|
399
|
-
|
|
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; }
|
|
400
440
|
}
|
|
401
441
|
export function serveUi(req, res) { const path = new URL(req.url ?? '/', 'http://localhost').pathname; if (path !== '/_keys/ui' && path !== '/_keys/ui/')
|
|
402
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.
|
|
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",
|