@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/cli.js ADDED
@@ -0,0 +1,700 @@
1
+ #!/usr/bin/env node
2
+ import { access, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { saveJsonAtomic, defaultConfig } from './config.js';
10
+ import { createKhatServer } from './server.js';
11
+ import { openStore, maskSecret } from './store.js';
12
+ import { Vault, generateAccessToken, ACCESS_TOKEN_REF } from './vault.js';
13
+ import { createKeyProtector } from './key-protector.js';
14
+ import { exportPortable, importPortable } from './portable-vault.js';
15
+ import { LogWriter } from './logger.js';
16
+ import { runDoctor as inspectDoctor, formatDoctorSuggestion } from './doctor.js';
17
+ const dataDir = process.env.KHAT_HOME ?? join(homedir(), '.khat');
18
+ const configPath = join(dataDir, 'config.json');
19
+ const statePath = join(dataDir, 'state.json');
20
+ const vaultPath = join(dataDir, 'vault.json');
21
+ const pidPath = join(dataDir, 'daemon.pid');
22
+ const signalPath = join(dataDir, 'daemon.signal');
23
+ const execFileAsync = promisify(execFile);
24
+ const USAGE = `khat — local API key proxy
25
+
26
+ Usage: khat <command> [arguments]
27
+
28
+ Setup
29
+ init first-time setup: create vault + access token + default config
30
+ start [--foreground] start the proxy as a background daemon (or foreground with --foreground)
31
+ stop stop the background daemon
32
+ status show providers, keys, routes and key health
33
+ doctor detect local Agent Tools and show connection guidance
34
+ log [--tail <n>] show recent masked proxy request logs
35
+ ui issue a one-time browser ticket for the management page
36
+ tui open the interactive terminal management UI
37
+ export <file> export encrypted portable vault
38
+ import <file> [--force] import encrypted portable vault
39
+
40
+ Access token
41
+ token show print the current access token
42
+ token rotate generate a new access token (update your tools afterwards)
43
+
44
+ Providers
45
+ provider add <id> --base-url <url> [--name <n>] [--protocol openai|anthropic]
46
+ provider list
47
+ provider update <id> [--name <n>] [--base-url <url>] [--protocol openai|anthropic]
48
+ provider remove <id> removes the provider, its keys and its routes
49
+
50
+ Keys (secrets live in the encrypted vault, config keeps only references)
51
+ key add <provider> <id> [--weight <n>] [--value <v>] (prompts for the value when omitted)
52
+ --weight <n> relative share in weighted round-robin across the provider's keys (default 1)
53
+ key list [<provider>]
54
+ key update <provider> <id> [--weight <n>] [--value <v>]
55
+ key remove <provider> <id>
56
+ key enable <provider> <id> reset a key marked unavailable
57
+ key test <provider> <id> [--model <m>] send a minimal request to verify the key
58
+
59
+ Routes
60
+ route add <model> <provider>
61
+ route update <model> <provider>
62
+ route list
63
+ route remove <model>
64
+
65
+ Environment
66
+ KHAT_HOME data directory (default ~/.khat)
67
+ KHAT_ACCESS_TOKEN override the vault access token (mainly for tests)`;
68
+ function parseArgs(argv) {
69
+ const positionals = [];
70
+ const flags = {};
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const arg = argv[i];
73
+ if (!arg.startsWith('--')) {
74
+ positionals.push(arg);
75
+ continue;
76
+ }
77
+ const body = arg.slice(2);
78
+ const eq = body.indexOf('=');
79
+ if (eq >= 0) {
80
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
81
+ continue;
82
+ }
83
+ const next = argv[i + 1];
84
+ if (next !== undefined && !next.startsWith('--')) {
85
+ flags[body] = next;
86
+ i++;
87
+ }
88
+ else
89
+ flags[body] = true;
90
+ }
91
+ return { positionals, flags };
92
+ }
93
+ function flagString(flags, name) {
94
+ const value = flags[name];
95
+ return typeof value === 'string' ? value : undefined;
96
+ }
97
+ function flagInt(flags, name, fallback) {
98
+ const value = flagString(flags, name);
99
+ if (value === undefined)
100
+ return fallback;
101
+ const parsed = Number(value);
102
+ if (!Number.isInteger(parsed) || parsed < 1)
103
+ throw new Error(`--${name} must be a positive integer`);
104
+ return parsed;
105
+ }
106
+ async function promptSecret(label) {
107
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
108
+ try {
109
+ const value = (await rl.question(`${label}: `)).trim();
110
+ if (!value)
111
+ throw new Error('empty value');
112
+ return value;
113
+ }
114
+ finally {
115
+ rl.close();
116
+ }
117
+ }
118
+ async function promptPassword(label) {
119
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
120
+ throw new Error(`${label} requires an interactive terminal; passwords cannot be supplied via flags or environment variables`);
121
+ process.stdout.write(`${label}: `);
122
+ return new Promise((resolve, reject) => {
123
+ const stdin = process.stdin;
124
+ let value = '';
125
+ const onData = (chunk) => { const text = chunk.toString(); if (text.includes('\\n') || text.includes('\\r')) {
126
+ stdin.setRawMode?.(false);
127
+ stdin.pause();
128
+ stdin.off('data', onData);
129
+ process.stdout.write('\\n');
130
+ value = value.trim();
131
+ value ? resolve(value) : reject(new Error('empty password'));
132
+ }
133
+ else
134
+ value += text; };
135
+ stdin.setRawMode?.(true);
136
+ stdin.resume();
137
+ stdin.on('data', onData);
138
+ });
139
+ }
140
+ async function fileExists(path) {
141
+ try {
142
+ await access(path);
143
+ return true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ async function ensureDaemon() {
150
+ if (await daemonRunning())
151
+ return;
152
+ await runStart(false);
153
+ }
154
+ async function adminRequest(path, init = {}) {
155
+ const store = await openInitializedStore();
156
+ const token = process.env.KHAT_ACCESS_TOKEN ?? store.vault.get(ACCESS_TOKEN_REF);
157
+ if (!token)
158
+ throw new Error('no access token found; run khat init');
159
+ await ensureDaemon();
160
+ const response = await fetch(`http://${store.config.bind}:${store.config.port}${path}`, { ...init, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...(init.headers ?? {}) } });
161
+ const body = await response.json().catch(() => ({}));
162
+ if (!response.ok)
163
+ throw new Error(body?.error?.message ?? `Admin API returned ${response.status}`);
164
+ return body;
165
+ }
166
+ async function openInitializedStore() {
167
+ if (!(await fileExists(vaultPath)))
168
+ throw new Error(`not initialized; run 'khat init' first (expected vault at ${vaultPath})`);
169
+ return openStore(dataDir, createKeyProtector());
170
+ }
171
+ function keyStatusLine(store, providerId, keyId) {
172
+ const state = store.states[`${providerId}/${keyId}`];
173
+ if (!state || state.status === 'available')
174
+ return { status: 'available', note: '' };
175
+ return { status: 'unavailable', note: ` last error: ${state.lastError?.http ?? '?'} at ${state.lastError?.at ?? '?'}` };
176
+ }
177
+ function printStatus(store) {
178
+ const token = store.vault.get(ACCESS_TOKEN_REF);
179
+ console.log(`khat (${dataDir})`);
180
+ console.log(` listen: http://${store.config.bind}:${store.config.port}`);
181
+ console.log(` access token: ${token ? maskSecret(token) : '(none — run khat init)'} (khat token show)`);
182
+ console.log(' providers:');
183
+ if (!store.config.providers.length)
184
+ console.log(' (none — khat provider add)');
185
+ for (const provider of store.config.providers) {
186
+ console.log(` ${provider.id} (${provider.name}) ${provider.protocol} ${provider.baseUrl}`);
187
+ if (!provider.keys.length) {
188
+ console.log(' (no keys — khat key add)');
189
+ continue;
190
+ }
191
+ for (const key of provider.keys) {
192
+ const secret = store.vault.get(key.vaultRef);
193
+ const { status, note } = keyStatusLine(store, provider.id, key.id);
194
+ const masked = secret ? maskSecret(secret) : '(missing from vault!)';
195
+ console.log(` ${key.id} weight ${key.weight} ${status} ${masked}${note}`);
196
+ }
197
+ }
198
+ console.log(' routes:');
199
+ if (!store.config.routes.length)
200
+ console.log(' (none — khat route add)');
201
+ for (const route of store.config.routes)
202
+ console.log(` ${route.model} -> ${route.provider}`);
203
+ }
204
+ async function daemonRunning() {
205
+ let pid;
206
+ try {
207
+ pid = Number.parseInt((await readFile(pidPath, 'utf8')).trim(), 10);
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ if (!Number.isInteger(pid) || pid < 1)
213
+ return false;
214
+ try {
215
+ process.kill(pid, 0);
216
+ return true;
217
+ }
218
+ catch {
219
+ await unlink(pidPath).catch(() => undefined);
220
+ return false;
221
+ }
222
+ }
223
+ async function runForeground() {
224
+ const store = await openInitializedStore();
225
+ const secrets = {};
226
+ for (const provider of store.config.providers) {
227
+ for (const key of provider.keys) {
228
+ const secret = store.vault.get(key.vaultRef);
229
+ if (secret === undefined)
230
+ throw new Error(`vault is missing the secret for ${key.vaultRef} (provider ${provider.id}, key ${key.id}); fix with khat key remove + khat key add`);
231
+ secrets[key.vaultRef] = secret;
232
+ }
233
+ }
234
+ const accessToken = process.env.KHAT_ACCESS_TOKEN ?? store.vault.get(ACCESS_TOKEN_REF);
235
+ if (!accessToken)
236
+ throw new Error('no access token found (vault has none and KHAT_ACCESS_TOKEN is unset); run khat init');
237
+ const server = createKhatServer({ config: store.config, states: store.states, statePath, accessToken, secrets, store });
238
+ server.listen(store.config.port, store.config.bind, () => console.log(`khat listening on http://${store.config.bind}:${store.config.port}`));
239
+ const shutdown = () => {
240
+ console.log('shutting down…');
241
+ server.close(() => process.exit(0));
242
+ setTimeout(() => process.exit(0), 30_000).unref();
243
+ };
244
+ process.once('SIGINT', shutdown);
245
+ process.once('SIGTERM', shutdown);
246
+ }
247
+ async function runStart(foreground) {
248
+ if (foreground)
249
+ return runForeground();
250
+ const store = await openInitializedStore();
251
+ if (await daemonRunning())
252
+ throw new Error('khat is already running; use khat stop first');
253
+ const occupiedPid = await findListeningPid(store.config.port);
254
+ if (occupiedPid !== undefined)
255
+ throw new Error(`port ${store.config.port} is already occupied by process ${occupiedPid}; run 'khat stop' or stop it with taskkill /PID ${occupiedPid} /T /F`);
256
+ await unlink(signalPath).catch(() => undefined);
257
+ const supervisorPath = fileURLToPath(new URL('./supervisor.js', import.meta.url));
258
+ const child = spawn(process.execPath, [supervisorPath], {
259
+ cwd: process.cwd(),
260
+ detached: true,
261
+ stdio: 'ignore',
262
+ windowsHide: true,
263
+ env: process.env
264
+ });
265
+ child.unref();
266
+ const token = process.env.KHAT_ACCESS_TOKEN ?? store.vault.get(ACCESS_TOKEN_REF);
267
+ if (!token)
268
+ throw new Error('no access token found; run khat init');
269
+ const started = await new Promise((resolve) => {
270
+ const deadline = Date.now() + 5_000;
271
+ const poll = async () => {
272
+ try {
273
+ const health = await fetch(`http://${store.config.bind}:${store.config.port}/_keys/health`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(500) });
274
+ if (health.ok) {
275
+ resolve(true);
276
+ return;
277
+ }
278
+ }
279
+ catch { }
280
+ if (Date.now() >= deadline) {
281
+ resolve(false);
282
+ return;
283
+ }
284
+ setTimeout(poll, 50);
285
+ };
286
+ void poll();
287
+ });
288
+ if (!started) {
289
+ await runStop().catch(() => undefined);
290
+ throw new Error('daemon started but health check failed; run khat start --foreground for diagnostics');
291
+ }
292
+ console.log(`khat started on http://${store.config.bind}:${store.config.port}`);
293
+ }
294
+ async function findListeningPid(port) {
295
+ if (process.platform !== 'win32')
296
+ return undefined;
297
+ try {
298
+ const { stdout } = await execFileAsync('netstat', ['-ano', '-p', 'tcp']);
299
+ const pattern = new RegExp(`^\\s*TCP\\s+[^\\s:]+:${port}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)\\s*$`, 'im');
300
+ const match = stdout.match(pattern);
301
+ return match ? Number.parseInt(match[1], 10) : undefined;
302
+ }
303
+ catch {
304
+ return undefined;
305
+ }
306
+ }
307
+ async function runStop() {
308
+ if (!(await daemonRunning())) {
309
+ const store = await openInitializedStore();
310
+ const listenerPid = await findListeningPid(store.config.port);
311
+ if (!listenerPid) {
312
+ console.log('khat is not running');
313
+ return;
314
+ }
315
+ try {
316
+ await execFileAsync('taskkill', ['/PID', String(listenerPid), '/T', '/F']);
317
+ console.log(`stopped orphaned khat process (pid ${listenerPid})`);
318
+ return;
319
+ }
320
+ catch {
321
+ throw new Error(`port ${store.config.port} is occupied by process ${listenerPid}; stop it manually with taskkill /PID ${listenerPid} /T /F`);
322
+ }
323
+ }
324
+ await writeFile(signalPath, 'stop\n', { mode: 0o600 });
325
+ const deadline = Date.now() + 35_000;
326
+ while (await daemonRunning()) {
327
+ if (Date.now() >= deadline)
328
+ throw new Error('timed out waiting for khat to stop');
329
+ await new Promise((resolve) => setTimeout(resolve, 100));
330
+ }
331
+ console.log('khat stopped');
332
+ }
333
+ async function runTui() {
334
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
335
+ throw new Error("khat tui requires an interactive terminal; use 'khat status', 'khat log', or 'khat ui' instead");
336
+ }
337
+ const store = await openInitializedStore();
338
+ const token = process.env.KHAT_ACCESS_TOKEN ?? store.vault.get(ACCESS_TOKEN_REF);
339
+ if (!token)
340
+ throw new Error('no access token found; run khat init');
341
+ await ensureDaemon();
342
+ const { runTui: startTui } = await import('./tui-main.js');
343
+ await startTui(`http://${store.config.bind}:${store.config.port}`, token);
344
+ }
345
+ async function runUi() {
346
+ const store = await openInitializedStore();
347
+ const token = process.env.KHAT_ACCESS_TOKEN ?? store.vault.get(ACCESS_TOKEN_REF);
348
+ if (!token)
349
+ throw new Error('no access token found; run khat init');
350
+ const baseUrl = `http://${store.config.bind}:${store.config.port}`;
351
+ let response;
352
+ try {
353
+ response = await fetch(`${baseUrl}/_keys/ticket`, { method: 'POST', headers: { authorization: `Bearer ${token}` } });
354
+ }
355
+ catch {
356
+ throw new Error('khat is not running; start it with khat start');
357
+ }
358
+ if (!response.ok)
359
+ throw new Error(`could not issue UI ticket: ${response.status}`);
360
+ const { ticket } = await response.json();
361
+ const url = `${baseUrl}/_keys/ui?ticket=${encodeURIComponent(ticket)}`;
362
+ const command = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
363
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
364
+ const browser = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
365
+ browser.unref();
366
+ console.log(`Opening management page. If it does not open, visit:\n ${url}`);
367
+ }
368
+ async function runLog(flags) {
369
+ const tail = flagInt(flags, 'tail', 50);
370
+ const entries = await new LogWriter(join(dataDir, 'logs')).recent(tail);
371
+ if (!entries.length) {
372
+ console.log('(no request logs)');
373
+ return;
374
+ }
375
+ for (const entry of entries) {
376
+ console.log(`${entry.ts}\t${entry.status}\t${entry.provider}/${entry.key.split('/').pop()}\t${entry.model}\t${entry.durationMs}ms\t${entry.tokensIn ?? 0}/${entry.tokensOut ?? 0} tokens`);
377
+ }
378
+ }
379
+ async function runDoctor() {
380
+ console.log('khat doctor');
381
+ console.log(` data directory: ${dataDir}`);
382
+ console.log(` proxy: ${await daemonRunning() ? 'running' : 'stopped'}`);
383
+ const suggestions = await inspectDoctor();
384
+ const names = new Set(suggestions.map((item) => item.tool));
385
+ for (const item of suggestions)
386
+ for (const line of formatDoctorSuggestion(item))
387
+ console.log(line);
388
+ for (const name of ['Codex', 'OpenCode', 'ZCode', 'Cursor'])
389
+ if (!names.has(name))
390
+ console.log(` ${name}: not detected`);
391
+ if (!(await fileExists(vaultPath)))
392
+ console.log(" setup: not initialized; run 'khat init'");
393
+ }
394
+ async function runExport(path) {
395
+ if (!path)
396
+ throw new Error("usage: khat export <file>");
397
+ const store = await openInitializedStore();
398
+ if (await daemonRunning())
399
+ throw new Error("khat daemon is running; stop it before export");
400
+ const password = await promptPassword("Export password");
401
+ await exportPortable(path, store.vault, password, store.config);
402
+ console.log(`exported encrypted vault to ${path}`);
403
+ }
404
+ async function runImport(path, force) {
405
+ if (!path)
406
+ throw new Error("usage: khat import <file> [--force]");
407
+ if (await fileExists(vaultPath) && !force)
408
+ throw new Error(`target is already initialized at ${dataDir}; use --force to replace it`);
409
+ if (await daemonRunning())
410
+ throw new Error("khat daemon is running; stop it before import");
411
+ const password = await promptPassword("Import password");
412
+ await importPortable(path, dataDir, createKeyProtector(), password, force);
413
+ console.log(`imported encrypted vault into ${dataDir}`);
414
+ }
415
+ async function runInit() {
416
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
417
+ if (await fileExists(configPath)) {
418
+ console.error(`already initialized at ${configPath} (delete the directory to start over)`);
419
+ process.exitCode = 1;
420
+ return;
421
+ }
422
+ const vault = await Vault.create(vaultPath, createKeyProtector());
423
+ const token = generateAccessToken();
424
+ vault.set(ACCESS_TOKEN_REF, token);
425
+ await vault.save();
426
+ await saveJsonAtomic(configPath, defaultConfig);
427
+ await saveJsonAtomic(statePath, { keys: {} });
428
+ console.log(`Initialized ${dataDir}`);
429
+ console.log();
430
+ console.log('Access token (also available via `khat token show`):');
431
+ console.log(` ${token}`);
432
+ console.log();
433
+ console.log('Point your tools at the proxy, e.g.:');
434
+ console.log(` base URL: http://${defaultConfig.bind}:${defaultConfig.port}/v1`);
435
+ console.log(` Authorization: Bearer ${token}`);
436
+ }
437
+ const [command, ...rest] = process.argv.slice(2);
438
+ const { positionals, flags } = parseArgs(rest);
439
+ try {
440
+ switch (command ?? 'help') {
441
+ case 'help':
442
+ case '--help':
443
+ case '-h':
444
+ console.log(USAGE);
445
+ break;
446
+ case 'init':
447
+ await runInit();
448
+ break;
449
+ case 'export':
450
+ await runExport(positionals[0]);
451
+ break;
452
+ case 'import':
453
+ await runImport(positionals[0], flags.force === true);
454
+ break;
455
+ case 'start':
456
+ await runStart(flags.foreground === true);
457
+ break;
458
+ case 'stop':
459
+ await runStop();
460
+ break;
461
+ case 'status':
462
+ printStatus(await openInitializedStore());
463
+ break;
464
+ case 'doctor':
465
+ await runDoctor();
466
+ break;
467
+ case 'log':
468
+ await runLog(flags);
469
+ break;
470
+ case 'ui':
471
+ await runUi();
472
+ break;
473
+ case 'tui':
474
+ await runTui();
475
+ break;
476
+ case 'token': {
477
+ if (positionals[0] === 'show') {
478
+ const store = await openInitializedStore();
479
+ const token = store.vault.get(ACCESS_TOKEN_REF);
480
+ if (!token) {
481
+ console.error('no access token in the vault; run khat init');
482
+ process.exitCode = 1;
483
+ break;
484
+ }
485
+ console.log(token);
486
+ }
487
+ else if (positionals[0] === 'rotate') {
488
+ const { token } = await adminRequest('/_keys/token/rotate', { method: 'POST' });
489
+ console.log('Access token rotated. Update every tool that points at khat:');
490
+ console.log(` ${token}`);
491
+ }
492
+ else {
493
+ console.error("usage: khat token show | khat token rotate");
494
+ process.exitCode = 1;
495
+ }
496
+ break;
497
+ }
498
+ case 'provider': {
499
+ const action = positionals[0];
500
+ if (action === 'add') {
501
+ const id = positionals[1];
502
+ const baseUrl = flagString(flags, 'base-url');
503
+ if (!id || !baseUrl) {
504
+ console.error('usage: khat provider add <id> --base-url <url> [--name <n>] [--protocol openai|anthropic]');
505
+ process.exitCode = 1;
506
+ break;
507
+ }
508
+ const protocol = (flagString(flags, 'protocol') ?? 'openai');
509
+ if (protocol !== 'openai' && protocol !== 'anthropic')
510
+ throw new Error(`unsupported protocol '${protocol}' (use 'openai' or 'anthropic')`);
511
+ await adminRequest('/_keys/providers', { method: 'POST', body: JSON.stringify({ id, name: flagString(flags, 'name'), protocol, baseUrl: baseUrl.replace(/\/+$/, '') }) });
512
+ console.log(`added provider ${id} -> ${baseUrl}`);
513
+ }
514
+ else if (action === 'list') {
515
+ const store = await openInitializedStore();
516
+ if (!store.config.providers.length)
517
+ console.log('(no providers)');
518
+ for (const provider of store.config.providers)
519
+ console.log(`${provider.id}\t${provider.protocol}\t${provider.baseUrl}\t${provider.keys.length} key(s)`);
520
+ }
521
+ else if (action === 'update') {
522
+ const id = positionals[1];
523
+ if (!id || (!flagString(flags, 'name') && !flagString(flags, 'base-url') && !flagString(flags, 'protocol')))
524
+ throw new Error('usage: khat provider update <id> [--name <n>] [--base-url <url>] [--protocol openai|anthropic]');
525
+ const protocol = flagString(flags, 'protocol');
526
+ if (protocol && protocol !== 'openai' && protocol !== 'anthropic')
527
+ throw new Error(`unsupported protocol '${protocol}'`);
528
+ await adminRequest(`/_keys/providers/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ name: flagString(flags, 'name'), baseUrl: flagString(flags, 'base-url'), protocol }) });
529
+ console.log(`updated provider ${id}`);
530
+ }
531
+ else if (action === 'remove') {
532
+ const id = positionals[1];
533
+ if (!id) {
534
+ console.error('usage: khat provider remove <id>');
535
+ process.exitCode = 1;
536
+ break;
537
+ }
538
+ await adminRequest(`/_keys/providers/${encodeURIComponent(id)}`, { method: 'DELETE' });
539
+ console.log(`removed provider ${id} (its keys and routes were removed too)`);
540
+ }
541
+ else {
542
+ console.error('usage: khat provider add | list | update | remove');
543
+ process.exitCode = 1;
544
+ }
545
+ break;
546
+ }
547
+ case 'key': {
548
+ const action = positionals[0];
549
+ if (action === 'add') {
550
+ const [providerId, keyId] = [positionals[1], positionals[2]];
551
+ if (!providerId || !keyId) {
552
+ console.error('usage: khat key add <provider> <id> [--weight <n>] [--value <v>]');
553
+ process.exitCode = 1;
554
+ break;
555
+ }
556
+ const value = flagString(flags, 'value') ?? (await promptSecret(`Paste the API key for ${providerId}/${keyId}`));
557
+ await adminRequest(`/_keys/providers/${encodeURIComponent(providerId)}/keys`, { method: 'POST', body: JSON.stringify({ id: keyId, value, weight: flagInt(flags, 'weight', 1) }) });
558
+ console.log(`added key ${providerId}/${keyId} (stored encrypted in vault)`);
559
+ }
560
+ else if (action === 'list') {
561
+ const store = await openInitializedStore();
562
+ const providers = positionals[1] ? store.config.providers.filter((item) => item.id === positionals[1]) : store.config.providers;
563
+ if (positionals[1] && !providers.length)
564
+ throw new Error(`unknown provider: ${positionals[1]}`);
565
+ for (const provider of providers) {
566
+ for (const key of provider.keys) {
567
+ const secret = store.vault.get(key.vaultRef);
568
+ const { status, note } = keyStatusLine(store, provider.id, key.id);
569
+ console.log(`${provider.id}/${key.id}\tweight ${key.weight}\t${status}\t${secret ? maskSecret(secret) : '(missing from vault!)'}${note ? `\t${note}` : ''}`);
570
+ }
571
+ }
572
+ }
573
+ else if (action === 'remove') {
574
+ const [providerId, keyId] = [positionals[1], positionals[2]];
575
+ if (!providerId || !keyId) {
576
+ console.error('usage: khat key remove <provider> <id>');
577
+ process.exitCode = 1;
578
+ break;
579
+ }
580
+ await adminRequest(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE' });
581
+ console.log(`removed key ${providerId}/${keyId}`);
582
+ }
583
+ else if (action === 'update') {
584
+ const [providerId, keyId] = [positionals[1], positionals[2]];
585
+ if (!providerId || !keyId || (flagString(flags, 'weight') === undefined && flagString(flags, 'value') === undefined))
586
+ throw new Error('usage: khat key update <provider> <id> [--weight <n>] [--value <v>]');
587
+ await adminRequest(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}`, { method: 'PUT', body: JSON.stringify({ weight: flagString(flags, 'weight') === undefined ? undefined : flagInt(flags, 'weight', 1), value: flagString(flags, 'value') }) });
588
+ console.log(`updated key ${providerId}/${keyId}`);
589
+ }
590
+ else if (action === 'enable') {
591
+ const [providerId, keyId] = [positionals[1], positionals[2]];
592
+ if (!providerId || !keyId) {
593
+ console.error('usage: khat key enable <provider> <id>');
594
+ process.exitCode = 1;
595
+ break;
596
+ }
597
+ await adminRequest(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/enable`, { method: 'POST' });
598
+ console.log(`${providerId}/${keyId} is available again`);
599
+ }
600
+ else if (action === 'test') {
601
+ const [providerId, keyId] = [positionals[1], positionals[2]];
602
+ if (!providerId || !keyId) {
603
+ console.error('usage: khat key test <provider> <id> [--model <m>]');
604
+ process.exitCode = 1;
605
+ break;
606
+ }
607
+ const store = await openInitializedStore();
608
+ const provider = store.config.providers.find((item) => item.id === providerId);
609
+ if (!provider)
610
+ throw new Error(`unknown provider: ${providerId}`);
611
+ const key = provider.keys.find((item) => item.id === keyId);
612
+ if (!key)
613
+ throw new Error(`unknown key: ${providerId}/${keyId}`);
614
+ const secret = store.vault.get(key.vaultRef);
615
+ if (!secret)
616
+ throw new Error(`vault is missing the secret for ${key.vaultRef}`);
617
+ const model = flagString(flags, 'model') ?? store.config.routes.find((route) => route.provider === providerId)?.model;
618
+ if (!model)
619
+ throw new Error('no route tells which model to probe; pass --model <name>');
620
+ const anthropic = provider.protocol === 'anthropic';
621
+ const path = anthropic ? '/v1/messages' : '/v1/chat/completions';
622
+ const headers = anthropic
623
+ ? { 'content-type': 'application/json', 'x-api-key': secret, 'anthropic-version': '2023-06-01' }
624
+ : { 'content-type': 'application/json', authorization: `Bearer ${secret}` };
625
+ const body = anthropic
626
+ ? JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] })
627
+ : JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false });
628
+ console.log(`probing ${provider.baseUrl}${path} with model ${model}…`);
629
+ const response = await fetch(new URL(path, provider.baseUrl), {
630
+ method: 'POST',
631
+ headers,
632
+ body,
633
+ signal: AbortSignal.timeout(30_000)
634
+ });
635
+ console.log(`HTTP ${response.status}`);
636
+ if (response.ok) {
637
+ await adminRequest(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/enable`, { method: 'POST' });
638
+ console.log(`${providerId}/${keyId} is available`);
639
+ }
640
+ else if ([401, 402, 429].includes(response.status))
641
+ console.log('this error marks a key unavailable during proxying; check the key on the provider side');
642
+ }
643
+ else {
644
+ console.error('usage: khat key add | list | update | remove | enable | test');
645
+ process.exitCode = 1;
646
+ }
647
+ break;
648
+ }
649
+ case 'route': {
650
+ const action = positionals[0];
651
+ if (action === 'add') {
652
+ const [model, providerId] = [positionals[1], positionals[2]];
653
+ if (!model || !providerId) {
654
+ console.error('usage: khat route add <model> <provider>');
655
+ process.exitCode = 1;
656
+ break;
657
+ }
658
+ await adminRequest('/_keys/routes', { method: 'POST', body: JSON.stringify({ model, provider: providerId }) });
659
+ console.log(`route added: ${model} -> ${providerId}`);
660
+ }
661
+ else if (action === 'list') {
662
+ const store = await openInitializedStore();
663
+ if (!store.config.routes.length)
664
+ console.log('(no routes)');
665
+ for (const route of store.config.routes)
666
+ console.log(`${route.model} -> ${route.provider}`);
667
+ }
668
+ else if (action === 'update') {
669
+ const [model, providerId] = [positionals[1], positionals[2]];
670
+ if (!model || !providerId)
671
+ throw new Error('usage: khat route update <model> <provider>');
672
+ await adminRequest(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: JSON.stringify({ provider: providerId }) });
673
+ console.log(`route updated: ${model} -> ${providerId}`);
674
+ }
675
+ else if (action === 'remove') {
676
+ const model = positionals[1];
677
+ if (!model) {
678
+ console.error('usage: khat route remove <model>');
679
+ process.exitCode = 1;
680
+ break;
681
+ }
682
+ await adminRequest(`/_keys/routes?model=${encodeURIComponent(model)}`, { method: 'DELETE' });
683
+ console.log(`route removed: ${model}`);
684
+ }
685
+ else {
686
+ console.error('usage: khat route add | list | update | remove');
687
+ process.exitCode = 1;
688
+ }
689
+ break;
690
+ }
691
+ default:
692
+ console.error(`unknown command: ${command}\n`);
693
+ console.log(USAGE);
694
+ process.exitCode = 1;
695
+ }
696
+ }
697
+ catch (error) {
698
+ console.error(`error: ${error?.message ?? error}`);
699
+ process.exitCode = 1;
700
+ }