@alilis/k-hat 0.1.0

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/server.js ADDED
@@ -0,0 +1,228 @@
1
+ import { createServer } from 'node:http';
2
+ import { watch } from 'node:fs';
3
+ import { readJsonFile, saveJsonAtomic, validateConfig, defaultTimeouts } from './config.js';
4
+ import { LogWriter } from './logger.js';
5
+ import { join, basename } from 'node:path';
6
+ import { resolveProvider } from './router.js';
7
+ import { WeightedSelector } from './selector.js';
8
+ import { handleAdmin } from './admin.js';
9
+ import { ACCESS_TOKEN_REF } from './vault.js';
10
+ const RETRYABLE = new Set([401, 402, 429]);
11
+ const HOP_BY_HOP = new Set(['content-length', 'transfer-encoding', 'connection']);
12
+ const ANTHROPIC_VERSION = '2023-06-01';
13
+ /** Entry paths accepted by the proxy, mapped to the protocol family they speak. */
14
+ const ENDPOINTS = {
15
+ '/v1/chat/completions': 'openai',
16
+ '/v1/responses': 'openai',
17
+ '/v1/messages': 'anthropic'
18
+ };
19
+ /** Rewrite the client-facing auth into the upstream auth convention for the target protocol. */
20
+ function upstreamHeaders(protocol, secret, accept) {
21
+ if (protocol === 'anthropic')
22
+ return { 'content-type': 'application/json', accept: accept ?? '*/*', 'x-api-key': secret, 'anthropic-version': ANTHROPIC_VERSION };
23
+ return { 'content-type': 'application/json', accept: accept ?? '*/*', authorization: `Bearer ${secret}` };
24
+ }
25
+ async function readBody(req, limit) {
26
+ const chunks = [];
27
+ let size = 0;
28
+ for await (const chunk of req) {
29
+ const part = Buffer.from(chunk);
30
+ size += part.length;
31
+ if (size > limit)
32
+ throw Object.assign(new Error('Request body too large'), { statusCode: 413 });
33
+ chunks.push(part);
34
+ }
35
+ return Buffer.concat(chunks);
36
+ }
37
+ function scanUsage(protocol, text) {
38
+ let tokensIn = 0;
39
+ let tokensOut = 0;
40
+ for (const line of text.split('\n')) {
41
+ if (!line.startsWith('data: '))
42
+ continue;
43
+ try {
44
+ const usage = JSON.parse(line.slice(6)).usage;
45
+ if (!usage || typeof usage !== 'object')
46
+ continue;
47
+ 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;
50
+ }
51
+ else {
52
+ tokensIn += Number(usage.input_tokens ?? 0) || 0;
53
+ tokensOut += Number(usage.output_tokens ?? 0) || 0;
54
+ }
55
+ }
56
+ catch { }
57
+ }
58
+ return { tokensIn, tokensOut };
59
+ }
60
+ function json(res, status, value) { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); }
61
+ /** Abort reason carrier so timeouts map to 504 instead of a generic 502. */
62
+ function upstreamAbort(abort, message) {
63
+ const reason = new Error(message);
64
+ reason.upstreamTimeout = true;
65
+ abort.abort(reason);
66
+ }
67
+ export function createKhatServer(options) {
68
+ const states = options.states ?? {};
69
+ const secrets = { ...(options.secrets ?? {}) };
70
+ const currentSecret = (ref) => options.store?.vault.get(ref) ?? secrets[ref];
71
+ const selector = new WeightedSelector();
72
+ const statePath = options.statePath;
73
+ const logger = options.store ? new LogWriter(join(options.store.dir, 'logs')) : undefined;
74
+ 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() } };
77
+ if (statePath)
78
+ await saveJsonAtomic(statePath, { keys: states, counters: options.store?.counters ?? {} });
79
+ };
80
+ const server = createServer(async (req, res) => {
81
+ try {
82
+ if (req.url?.startsWith('/_keys')) {
83
+ if (!options.store)
84
+ return json(res, 503, { error: { message: 'Admin API is not available (no store attached)' } });
85
+ await handleAdmin(req, res, options.store, () => options.store?.vault.get(ACCESS_TOKEN_REF) ?? options.accessToken);
86
+ return;
87
+ }
88
+ const currentAccessToken = options.store?.vault.get(ACCESS_TOKEN_REF) ?? options.accessToken;
89
+ if (currentAccessToken && req.headers.authorization !== `Bearer ${currentAccessToken}`)
90
+ return json(res, 401, { error: { message: 'Unauthorized' } });
91
+ if (req.method !== 'POST' || !(req.url in ENDPOINTS))
92
+ return json(res, 404, { error: { message: 'Not found' } });
93
+ const protocol = ENDPOINTS[req.url];
94
+ const body = await readBody(req, options.config.requestBodyLimitMB * 1024 * 1024);
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(body.toString('utf8'));
98
+ }
99
+ catch {
100
+ return json(res, 400, { error: { message: 'Invalid JSON' } });
101
+ }
102
+ if (!parsed.model || typeof parsed.model !== 'string')
103
+ return json(res, 400, { error: { message: 'model is required' } });
104
+ const provider = resolveProvider(options.config, parsed.model);
105
+ if (!provider)
106
+ return json(res, 404, { error: { message: `No route for model: ${parsed.model}` } });
107
+ if (provider.protocol !== protocol)
108
+ return json(res, 400, { error: { message: `Model ${parsed.model} resolves to a ${provider.protocol} provider, but ${req.url} speaks ${protocol}` } });
109
+ const tried = new Set();
110
+ while (true) {
111
+ const key = selector.select(provider.id, provider.keys.filter((item) => !tried.has(item.id)), states);
112
+ if (!key)
113
+ 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' }) })) } });
114
+ tried.add(key.id);
115
+ const secret = currentSecret(key.vaultRef);
116
+ if (secret === undefined)
117
+ continue;
118
+ const abort = new AbortController();
119
+ const clientGone = () => abort.abort(new Error('client disconnected'));
120
+ res.on('close', clientGone);
121
+ const headerTimer = setTimeout(() => upstreamAbort(abort, 'upstream response header timeout'), timeouts().headerMs);
122
+ let upstream;
123
+ try {
124
+ upstream = await fetch(new URL(req.url, provider.baseUrl), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: new Uint8Array(body), signal: abort.signal });
125
+ }
126
+ catch (error) {
127
+ if (error?.upstreamTimeout)
128
+ error.statusCode = 504;
129
+ throw error;
130
+ }
131
+ finally {
132
+ clearTimeout(headerTimer);
133
+ }
134
+ if (RETRYABLE.has(upstream.status)) {
135
+ await markUnavailable(provider.id, key.id, upstream.status);
136
+ if (tried.size < provider.keys.length) {
137
+ res.off('close', clientGone);
138
+ continue;
139
+ }
140
+ }
141
+ const startedAt = Date.now();
142
+ let firstByteAt;
143
+ let bytes = 0;
144
+ let sseRemainder = '';
145
+ let tokensIn = 0;
146
+ let tokensOut = 0;
147
+ const responseHeaders = {};
148
+ upstream.headers.forEach((value, name) => { if (!HOP_BY_HOP.has(name))
149
+ responseHeaders[name] = value; });
150
+ res.writeHead(upstream.status, responseHeaders);
151
+ if (upstream.body) {
152
+ const idleTimer = setTimeout(() => upstreamAbort(abort, 'upstream stream idle timeout'), timeouts().streamIdleMs);
153
+ try {
154
+ for await (const chunk of upstream.body) {
155
+ const buffer = Buffer.from(chunk);
156
+ if (firstByteAt === undefined)
157
+ firstByteAt = Date.now();
158
+ bytes += buffer.length;
159
+ const combined = sseRemainder + buffer.toString('utf8');
160
+ const lastNewline = combined.lastIndexOf('\n');
161
+ if (lastNewline >= 0) {
162
+ const usage = scanUsage(protocol, combined.slice(0, lastNewline + 1));
163
+ tokensIn += usage.tokensIn;
164
+ tokensOut += usage.tokensOut;
165
+ sseRemainder = combined.slice(lastNewline + 1);
166
+ }
167
+ else
168
+ sseRemainder = combined;
169
+ res.write(buffer);
170
+ idleTimer.refresh();
171
+ }
172
+ const usage = scanUsage(protocol, sseRemainder);
173
+ tokensIn += usage.tokensIn;
174
+ tokensOut += usage.tokensOut;
175
+ }
176
+ catch (error) {
177
+ if (error?.upstreamTimeout)
178
+ res.destroy(error);
179
+ throw error;
180
+ }
181
+ finally {
182
+ clearTimeout(idleTimer);
183
+ }
184
+ }
185
+ res.end();
186
+ res.off('close', clientGone);
187
+ const durationMs = Date.now() - startedAt;
188
+ const ttfbMs = (firstByteAt ?? Date.now()) - startedAt;
189
+ const keyRef = `${provider.id}/${key.id}`;
190
+ options.store?.recordCounter(keyRef, { requests: 1, failed: upstream.ok ? 0 : 1, bytesOut: bytes, tokensIn, tokensOut });
191
+ void logger?.append({ ts: new Date().toISOString(), event: 'forward', model: parsed.model, provider: provider.id, key: keyRef, status: upstream.status, ttfbMs, durationMs, bytes, tokensIn, tokensOut });
192
+ return;
193
+ }
194
+ }
195
+ catch (error) {
196
+ if (!res.headersSent)
197
+ json(res, error.statusCode ?? 502, { error: { message: error.message ?? 'Proxy error' } });
198
+ else
199
+ res.destroy(error);
200
+ }
201
+ });
202
+ const configWatcher = options.store ? watch(options.store.dir, (_event, filename) => {
203
+ if (filename?.toString() !== basename(options.store.configPath))
204
+ return;
205
+ void (async () => {
206
+ try {
207
+ const next = await readJsonFile(options.store.configPath);
208
+ if (next === undefined)
209
+ return;
210
+ const validated = validateConfig(next);
211
+ Object.assign(options.config, validated);
212
+ for (const key of Object.keys(secrets))
213
+ delete secrets[key];
214
+ for (const provider of validated.providers)
215
+ for (const key of provider.keys) {
216
+ const secret = options.store?.vault.get(key.vaultRef);
217
+ if (secret !== undefined)
218
+ secrets[key.vaultRef] = secret;
219
+ }
220
+ }
221
+ catch (error) {
222
+ console.error(`[khat] ignored invalid external config update: ${error?.message ?? error}`);
223
+ }
224
+ })();
225
+ }) : undefined;
226
+ server.once('close', () => configWatcher?.close());
227
+ return server;
228
+ }
package/dist/store.js ADDED
@@ -0,0 +1,240 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readJsonFile, saveJsonAtomic, loadState, validateConfig, defaultConfig } from './config.js';
4
+ import { Vault } from './vault.js';
5
+ /** Ids appear in `provider/model` routing syntax and in vault refs, so they cannot contain `/`. */
6
+ const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
7
+ export function maskSecret(value) {
8
+ // Short values would be almost fully revealed by a 4-char tail, so mask them entirely.
9
+ return value.length <= 8 ? '****' : `****${value.slice(-4)}`;
10
+ }
11
+ function requireValidId(kind, id) {
12
+ if (!ID_PATTERN.test(id))
13
+ throw new Error(`${kind} id '${id}' is invalid: use letters, digits, '-' or '_'`);
14
+ }
15
+ export async function openStore(dir, protector) {
16
+ await mkdir(dir, { recursive: true, mode: 0o700 });
17
+ const configPath = join(dir, 'config.json');
18
+ const statePath = join(dir, 'state.json');
19
+ const vaultPath = join(dir, 'vault.json');
20
+ const vault = await Vault.openOrCreate(vaultPath, protector);
21
+ const raw = await readJsonFile(configPath);
22
+ const config = raw === undefined ? structuredClone(defaultConfig) : raw;
23
+ // Runs before validation: M1-era configs carry inline `value` fields and no vaultRef.
24
+ const migrated = raw !== undefined && migrateLegacyKeyValues(raw, vault);
25
+ validateConfig(config);
26
+ const persistedState = await loadState(statePath);
27
+ let stateTimer;
28
+ let mutationTail = Promise.resolve();
29
+ const store = {
30
+ dir, configPath, statePath, vaultPath, config, states: persistedState.keys, counters: persistedState.counters, vault,
31
+ mutate: async (operation) => {
32
+ const previous = mutationTail;
33
+ let release;
34
+ mutationTail = new Promise((resolve) => { release = resolve; });
35
+ await previous;
36
+ try {
37
+ return await operation();
38
+ }
39
+ finally {
40
+ release();
41
+ }
42
+ },
43
+ saveConfig: () => saveJsonAtomic(configPath, config),
44
+ saveState: () => saveJsonAtomic(statePath, { keys: store.states, counters: store.counters }),
45
+ recordCounter: (keyRef, delta) => {
46
+ const current = store.counters[keyRef] ?? { requests: 0, failed: 0, bytesOut: 0, tokensIn: 0, tokensOut: 0 };
47
+ store.counters[keyRef] = {
48
+ requests: current.requests + (delta.requests ?? 0),
49
+ failed: current.failed + (delta.failed ?? 0),
50
+ bytesOut: current.bytesOut + (delta.bytesOut ?? 0),
51
+ tokensIn: current.tokensIn + (delta.tokensIn ?? 0),
52
+ tokensOut: current.tokensOut + (delta.tokensOut ?? 0)
53
+ };
54
+ if (!stateTimer)
55
+ stateTimer = setTimeout(() => {
56
+ stateTimer = undefined;
57
+ void store.saveState();
58
+ }, 1_000);
59
+ stateTimer.unref();
60
+ }
61
+ };
62
+ if (migrated) {
63
+ // Vault first: a crash between the two writes leaves plaintext in config,
64
+ // which simply re-migrates on the next run; the reverse could lose secrets.
65
+ await vault.save();
66
+ await store.saveConfig();
67
+ }
68
+ return store;
69
+ }
70
+ /** Move remaining M1-era inline key values into the vault, leaving only vaultRefs in config. */
71
+ function migrateLegacyKeyValues(raw, vault) {
72
+ let changed = false;
73
+ for (const provider of raw?.providers ?? []) {
74
+ if (!provider?.id || !Array.isArray(provider.keys))
75
+ continue;
76
+ for (const key of provider.keys) {
77
+ if (typeof key.value !== 'string' || !key.value)
78
+ continue;
79
+ if (key.vaultRef === undefined)
80
+ key.vaultRef = `${provider.id}/${key.id}`;
81
+ if (vault.get(key.vaultRef) === undefined)
82
+ vault.set(key.vaultRef, key.value);
83
+ delete key.value;
84
+ changed = true;
85
+ }
86
+ }
87
+ return changed;
88
+ }
89
+ function findProvider(store, id) {
90
+ const provider = store.config.providers.find((item) => item.id === id);
91
+ if (!provider)
92
+ throw new Error(`unknown provider: ${id}`);
93
+ return provider;
94
+ }
95
+ async function persistConfig(store) {
96
+ validateConfig(store.config);
97
+ await store.saveConfig();
98
+ }
99
+ export async function addProvider(store, input) {
100
+ requireValidId('provider', input.id);
101
+ if (store.config.providers.some((item) => item.id === input.id))
102
+ throw new Error(`provider already exists: ${input.id}`);
103
+ store.config.providers.push({ id: input.id, name: input.name?.trim() || input.id, protocol: input.protocol, baseUrl: input.baseUrl, keys: [] });
104
+ try {
105
+ await persistConfig(store);
106
+ }
107
+ catch (error) {
108
+ store.config.providers.pop();
109
+ throw error;
110
+ }
111
+ }
112
+ export async function updateProvider(store, id, input) {
113
+ const provider = findProvider(store, id);
114
+ const previous = { name: provider.name, protocol: provider.protocol, baseUrl: provider.baseUrl };
115
+ if (input.name !== undefined)
116
+ provider.name = input.name.trim() || provider.id;
117
+ if (input.protocol !== undefined)
118
+ provider.protocol = input.protocol;
119
+ if (input.baseUrl !== undefined)
120
+ provider.baseUrl = input.baseUrl.replace(/\/+$/, '');
121
+ try {
122
+ await persistConfig(store);
123
+ }
124
+ catch (error) {
125
+ Object.assign(provider, previous);
126
+ throw error;
127
+ }
128
+ }
129
+ export async function removeProvider(store, id) {
130
+ const provider = findProvider(store, id);
131
+ store.config.providers = store.config.providers.filter((item) => item.id !== id);
132
+ store.config.routes = store.config.routes.filter((route) => route.provider !== id);
133
+ for (const key of provider.keys) {
134
+ store.vault.delete(key.vaultRef);
135
+ delete store.states[`${id}/${key.id}`];
136
+ }
137
+ await persistConfig(store);
138
+ await store.vault.save();
139
+ await store.saveState();
140
+ }
141
+ export async function addKey(store, providerId, keyId, value, weight = 1) {
142
+ const provider = findProvider(store, providerId);
143
+ requireValidId('key', keyId);
144
+ if (provider.keys.some((item) => item.id === keyId))
145
+ throw new Error(`key already exists: ${providerId}/${keyId}`);
146
+ const ref = `${providerId}/${keyId}`;
147
+ if (store.vault.get(ref) !== undefined)
148
+ throw new Error(`vault already holds a secret for ${ref}; remove the old key or pick another id`);
149
+ store.vault.set(ref, value);
150
+ provider.keys.push({ id: keyId, weight, vaultRef: ref });
151
+ try {
152
+ await persistConfig(store);
153
+ await store.vault.save();
154
+ }
155
+ catch (error) {
156
+ store.vault.delete(ref);
157
+ provider.keys.pop();
158
+ throw error;
159
+ }
160
+ }
161
+ export async function updateKey(store, providerId, keyId, input) {
162
+ const provider = findProvider(store, providerId);
163
+ const key = provider.keys.find((item) => item.id === keyId);
164
+ if (!key)
165
+ throw new Error(`unknown key: ${providerId}/${keyId}`);
166
+ const previousWeight = key.weight;
167
+ const previousValue = store.vault.get(key.vaultRef);
168
+ if (input.weight !== undefined)
169
+ key.weight = input.weight;
170
+ if (input.value !== undefined)
171
+ store.vault.set(key.vaultRef, input.value);
172
+ try {
173
+ await persistConfig(store);
174
+ if (input.value !== undefined)
175
+ await store.vault.save();
176
+ }
177
+ catch (error) {
178
+ key.weight = previousWeight;
179
+ if (previousValue === undefined)
180
+ store.vault.delete(key.vaultRef);
181
+ else
182
+ store.vault.set(key.vaultRef, previousValue);
183
+ throw error;
184
+ }
185
+ }
186
+ export async function removeKey(store, providerId, keyId) {
187
+ const provider = findProvider(store, providerId);
188
+ const key = provider.keys.find((item) => item.id === keyId);
189
+ if (!key)
190
+ throw new Error(`unknown key: ${providerId}/${keyId}`);
191
+ provider.keys = provider.keys.filter((item) => item.id !== keyId);
192
+ store.vault.delete(key.vaultRef);
193
+ delete store.states[`${providerId}/${keyId}`];
194
+ await persistConfig(store);
195
+ await store.vault.save();
196
+ await store.saveState();
197
+ }
198
+ export async function enableKey(store, providerId, keyId) {
199
+ const provider = findProvider(store, providerId);
200
+ if (!provider.keys.some((item) => item.id === keyId))
201
+ throw new Error(`unknown key: ${providerId}/${keyId}`);
202
+ delete store.states[`${providerId}/${keyId}`];
203
+ await store.saveState();
204
+ }
205
+ export async function addRoute(store, model, providerId) {
206
+ findProvider(store, providerId);
207
+ const existing = store.config.routes.find((route) => route.model === model);
208
+ if (existing)
209
+ throw new Error(`route already exists: ${model} -> ${existing.provider}`);
210
+ store.config.routes.push({ model, provider: providerId });
211
+ try {
212
+ await persistConfig(store);
213
+ }
214
+ catch (error) {
215
+ store.config.routes.pop();
216
+ throw error;
217
+ }
218
+ }
219
+ export async function updateRoute(store, model, providerId) {
220
+ findProvider(store, providerId);
221
+ const route = store.config.routes.find((item) => item.model === model);
222
+ if (!route)
223
+ throw new Error(`unknown route: ${model}`);
224
+ const previous = route.provider;
225
+ route.provider = providerId;
226
+ try {
227
+ await persistConfig(store);
228
+ }
229
+ catch (error) {
230
+ route.provider = previous;
231
+ throw error;
232
+ }
233
+ }
234
+ export async function removeRoute(store, model) {
235
+ const before = store.config.routes.length;
236
+ store.config.routes = store.config.routes.filter((route) => route.model !== model);
237
+ if (store.config.routes.length === before)
238
+ throw new Error(`unknown route: ${model}`);
239
+ await persistConfig(store);
240
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { readFile, unlink, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ const dataDir = process.env.KHAT_HOME ?? join(homedir(), '.khat');
8
+ const pidPath = join(dataDir, 'daemon.pid');
9
+ const signalPath = join(dataDir, 'daemon.signal');
10
+ const cliPath = fileURLToPath(new URL('./cli.js', import.meta.url));
11
+ const maxCrashes = 5;
12
+ const maxBackoffMs = 60_000;
13
+ function log(message) {
14
+ console.error(`[khat-supervisor] ${message}`);
15
+ }
16
+ async function removeIfPresent(path) {
17
+ try {
18
+ await unlink(path);
19
+ }
20
+ catch { }
21
+ }
22
+ async function main() {
23
+ await removeIfPresent(signalPath);
24
+ await writeFile(pidPath, `${process.pid}\n`, { mode: 0o600 });
25
+ let child;
26
+ let stopping = false;
27
+ let crashes = 0;
28
+ let restartTimer;
29
+ const cleanup = async (code) => {
30
+ if (restartTimer)
31
+ clearTimeout(restartTimer);
32
+ await removeIfPresent(signalPath);
33
+ await removeIfPresent(pidPath);
34
+ process.exit(code);
35
+ };
36
+ const stop = () => {
37
+ if (stopping)
38
+ return;
39
+ stopping = true;
40
+ if (restartTimer)
41
+ clearTimeout(restartTimer);
42
+ if (child && child.exitCode === null && !child.killed)
43
+ child.kill('SIGTERM');
44
+ else
45
+ void cleanup(0);
46
+ };
47
+ const launch = () => {
48
+ if (stopping)
49
+ return;
50
+ child = spawn(process.execPath, [cliPath, 'start', '--foreground'], {
51
+ cwd: process.cwd(),
52
+ env: process.env,
53
+ stdio: 'ignore',
54
+ windowsHide: true
55
+ });
56
+ child.unref();
57
+ child.once('error', (error) => {
58
+ log(`could not start daemon: ${error.message}`);
59
+ void cleanup(1);
60
+ });
61
+ child.once('exit', (code, signal) => {
62
+ child = undefined;
63
+ if (stopping) {
64
+ void cleanup(0);
65
+ return;
66
+ }
67
+ if (code === 0) {
68
+ void cleanup(0);
69
+ return;
70
+ }
71
+ crashes += 1;
72
+ if (crashes >= maxCrashes) {
73
+ log(`daemon crashed ${maxCrashes} times consecutively; run "khat doctor" for diagnostics`);
74
+ void cleanup(1);
75
+ return;
76
+ }
77
+ const delay = Math.min(1_000 * 2 ** (crashes - 1), maxBackoffMs);
78
+ log(`daemon exited (${signal ?? code ?? 'unknown'}); restarting in ${delay}ms`);
79
+ restartTimer = setTimeout(launch, delay);
80
+ });
81
+ };
82
+ const poll = setInterval(async () => {
83
+ if (stopping)
84
+ return;
85
+ try {
86
+ if ((await readFile(signalPath, 'utf8')).trim() === 'stop')
87
+ stop();
88
+ }
89
+ catch { }
90
+ }, 250);
91
+ process.once('SIGINT', stop);
92
+ process.once('SIGTERM', stop);
93
+ launch();
94
+ }
95
+ main().catch((error) => {
96
+ console.error(`[khat-supervisor] ${error.message}`);
97
+ process.exit(1);
98
+ });
@@ -0,0 +1,87 @@
1
+ export class TuiApiError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.status = status;
6
+ this.name = 'TuiApiError';
7
+ }
8
+ }
9
+ export class AdminClient {
10
+ baseUrl;
11
+ token;
12
+ constructor(baseUrl, token) {
13
+ this.baseUrl = baseUrl;
14
+ this.token = token;
15
+ }
16
+ async health() {
17
+ return this.request('/_keys/health');
18
+ }
19
+ async status() {
20
+ return this.request('/_keys/status');
21
+ }
22
+ async logs(tail = 50, filters = {}) {
23
+ const params = new URLSearchParams({ tail: String(tail) });
24
+ if (filters.model)
25
+ params.set('model', filters.model);
26
+ if (filters.key)
27
+ params.set('key', filters.key);
28
+ if (filters.status)
29
+ params.set('status', filters.status);
30
+ const result = await this.request(`/_keys/logs?${params}`);
31
+ return result.logs;
32
+ }
33
+ async rotateToken() {
34
+ return this.request('/_keys/token/rotate', { method: 'POST' });
35
+ }
36
+ async createProvider(input) {
37
+ await this.request('/_keys/providers', { method: 'POST', body: input });
38
+ }
39
+ async updateProvider(id, input) {
40
+ await this.request(`/_keys/providers/${encodeURIComponent(id)}`, { method: 'PUT', body: input });
41
+ }
42
+ async deleteProvider(id) {
43
+ await this.request(`/_keys/providers/${encodeURIComponent(id)}`, { method: 'DELETE' });
44
+ }
45
+ async createKey(providerId, input) {
46
+ await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys`, { method: 'POST', body: input });
47
+ }
48
+ async updateKey(providerId, keyId, input) {
49
+ await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}`, { method: 'PUT', body: input });
50
+ }
51
+ async deleteKey(providerId, keyId) {
52
+ await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE' });
53
+ }
54
+ async enableKey(providerId, keyId) {
55
+ await this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/enable`, { method: 'POST' });
56
+ }
57
+ async probeKey(providerId, keyId) {
58
+ return this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/probe`, { method: 'POST' });
59
+ }
60
+ async createRoute(model, provider) {
61
+ await this.request('/_keys/routes', { method: 'POST', body: { model, provider } });
62
+ }
63
+ async updateRoute(model, provider) {
64
+ await this.request(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: { provider } });
65
+ }
66
+ async deleteRoute(model) {
67
+ await this.request(`/_keys/routes?model=${encodeURIComponent(model)}`, { method: 'DELETE' });
68
+ }
69
+ async request(path, init = {}) {
70
+ let response;
71
+ try {
72
+ response = await fetch(`${this.baseUrl}${path}`, {
73
+ method: init.method ?? 'GET',
74
+ headers: { authorization: `Bearer ${this.token}`, accept: 'application/json', ...(init.body === undefined ? {} : { 'content-type': 'application/json' }) },
75
+ ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
76
+ signal: AbortSignal.timeout(5_000)
77
+ });
78
+ }
79
+ catch (error) {
80
+ throw new Error(`unable to reach khat daemon: ${error?.message ?? error}`);
81
+ }
82
+ const body = await response.json().catch(() => ({}));
83
+ if (!response.ok)
84
+ throw new TuiApiError(response.status, body?.error?.message ?? `Admin API returned ${response.status}`);
85
+ return body;
86
+ }
87
+ }
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink';
3
+ import { AdminClient } from './tui-client.js';
4
+ import { Tui } from './tui.js';
5
+ export async function runTui(baseUrl, token) {
6
+ const instance = render(_jsx(Tui, { client: new AdminClient(baseUrl, token) }));
7
+ await instance.waitUntilExit();
8
+ }
@@ -0,0 +1 @@
1
+ export {};