@alilis/k-hat 0.2.3 → 0.2.4
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 +3 -2
- package/dist/cli.js +3 -3
- package/dist/config.js +9 -2
- package/dist/server.js +2 -2
- package/dist/store.js +6 -2
- package/dist/tui.js +3 -1
- package/dist/web-ui.js +15 -8
- package/package.json +1 -1
package/dist/admin.js
CHANGED
|
@@ -3,6 +3,7 @@ import { LogWriter } from './logger.js';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { UiSessions, serveUi } from './web-ui.js';
|
|
5
5
|
import { ACCESS_TOKEN_REF, generateAccessToken } from './vault.js';
|
|
6
|
+
import { providerUrl } from './config.js';
|
|
6
7
|
// Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
|
|
7
8
|
// only from loopback and behind the proxy access token, so a local non-loopback
|
|
8
9
|
// client or a process without the token cannot mutate config/state/vault.
|
|
@@ -43,7 +44,7 @@ async function listProviderModels(store, providerId) {
|
|
|
43
44
|
if (!secret)
|
|
44
45
|
throw new Error('provider key secret is missing');
|
|
45
46
|
const headers = upstreamHeaders(provider.protocol, secret);
|
|
46
|
-
const response = await fetch(
|
|
47
|
+
const response = await fetch(providerUrl(provider.baseUrl, '/v1/models'), { headers, signal: AbortSignal.timeout(10_000) });
|
|
47
48
|
if (!response.ok)
|
|
48
49
|
throw new Error(`model query failed with HTTP ${response.status}`);
|
|
49
50
|
const body = await response.json();
|
|
@@ -186,7 +187,7 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
186
187
|
const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
|
|
187
188
|
const headers = upstreamHeaders(provider.protocol, secret, true);
|
|
188
189
|
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 };
|
|
189
|
-
const response = await fetch(
|
|
190
|
+
const response = await fetch(providerUrl(provider.baseUrl, path), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
|
|
190
191
|
if (response.ok)
|
|
191
192
|
await store.mutate(() => enableKey(store, provider.id, key.id));
|
|
192
193
|
return json(res, response.ok ? 200 : 502, { ok: response.ok, status: response.status });
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { createInterface } from 'node:readline/promises';
|
|
9
|
-
import { saveJsonAtomic, defaultConfig } from './config.js';
|
|
9
|
+
import { saveJsonAtomic, providerUrl, defaultConfig } from './config.js';
|
|
10
10
|
import { createKhatServer } from './server.js';
|
|
11
11
|
import { openStore, maskSecret } from './store.js';
|
|
12
12
|
import { Vault, generateAccessToken, ACCESS_TOKEN_REF } from './vault.js';
|
|
@@ -547,7 +547,7 @@ try {
|
|
|
547
547
|
const protocol = (flagString(flags, 'protocol') ?? 'openai');
|
|
548
548
|
if (protocol !== 'openai' && protocol !== 'anthropic')
|
|
549
549
|
throw new Error(`unsupported protocol '${protocol}' (use 'openai' or 'anthropic')`);
|
|
550
|
-
await adminRequest('/_keys/providers', { method: 'POST', body: JSON.stringify({ id, name: flagString(flags, 'name'), protocol, baseUrl
|
|
550
|
+
await adminRequest('/_keys/providers', { method: 'POST', body: JSON.stringify({ id, name: flagString(flags, 'name'), protocol, baseUrl }) });
|
|
551
551
|
console.log(`added provider ${id} -> ${baseUrl}`);
|
|
552
552
|
}
|
|
553
553
|
else if (action === 'list') {
|
|
@@ -665,7 +665,7 @@ try {
|
|
|
665
665
|
? JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] })
|
|
666
666
|
: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, stream: false });
|
|
667
667
|
console.log(`probing ${provider.baseUrl}${path} with model ${model}…`);
|
|
668
|
-
const response = await fetch(
|
|
668
|
+
const response = await fetch(providerUrl(provider.baseUrl, path), {
|
|
669
669
|
method: 'POST',
|
|
670
670
|
headers,
|
|
671
671
|
body,
|
package/dist/config.js
CHANGED
|
@@ -31,6 +31,13 @@ export async function saveJsonAtomic(path, value) {
|
|
|
31
31
|
function isLoopbackHost(hostname) {
|
|
32
32
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
33
33
|
}
|
|
34
|
+
export function providerUrl(baseUrl, path) {
|
|
35
|
+
const base = new URL(baseUrl);
|
|
36
|
+
const prefix = base.pathname.replace(/\/+$/, '');
|
|
37
|
+
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
38
|
+
base.pathname = `${prefix}${suffix}` || '/';
|
|
39
|
+
return base;
|
|
40
|
+
}
|
|
34
41
|
export function validateConfig(config) {
|
|
35
42
|
if (!config || config.version !== 1 || !Array.isArray(config.providers) || !Array.isArray(config.routes)) {
|
|
36
43
|
throw new Error('Invalid configuration: expected version 1 with providers and routes');
|
|
@@ -57,8 +64,8 @@ export function validateConfig(config) {
|
|
|
57
64
|
}
|
|
58
65
|
if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname))
|
|
59
66
|
throw new Error(`Provider ${provider.id}: baseUrl must use https (plain http is only allowed on loopback hosts)`);
|
|
60
|
-
if (url.
|
|
61
|
-
throw new Error(`Provider ${provider.id}: baseUrl must not contain a
|
|
67
|
+
if (url.search || url.hash)
|
|
68
|
+
throw new Error(`Provider ${provider.id}: baseUrl must not contain a query or hash`);
|
|
62
69
|
for (const key of provider.keys) {
|
|
63
70
|
if (!key.id || !Number.isInteger(key.weight) || key.weight < 1)
|
|
64
71
|
throw new Error(`Invalid key in provider ${provider.id}`);
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { watch } from 'node:fs';
|
|
3
|
-
import { readJsonFile, saveJsonAtomic, validateConfig, defaultTimeouts } from './config.js';
|
|
3
|
+
import { readJsonFile, saveJsonAtomic, validateConfig, providerUrl, defaultTimeouts } from './config.js';
|
|
4
4
|
import { LogWriter } from './logger.js';
|
|
5
5
|
import { join, basename } from 'node:path';
|
|
6
6
|
import { resolveProvider, isRouteDisabled } from './router.js';
|
|
@@ -124,7 +124,7 @@ export function createKhatServer(options) {
|
|
|
124
124
|
const headerTimer = setTimeout(() => upstreamAbort(abort, 'upstream response header timeout'), timeouts().headerMs);
|
|
125
125
|
let upstream;
|
|
126
126
|
try {
|
|
127
|
-
upstream = await fetch(
|
|
127
|
+
upstream = await fetch(providerUrl(provider.baseUrl, req.url), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: new Uint8Array(body), signal: abort.signal });
|
|
128
128
|
}
|
|
129
129
|
catch (error) {
|
|
130
130
|
if (error?.upstreamTimeout)
|
package/dist/store.js
CHANGED
|
@@ -12,6 +12,9 @@ function requireValidId(kind, id) {
|
|
|
12
12
|
if (!ID_PATTERN.test(id))
|
|
13
13
|
throw new Error(`${kind} id '${id}' is invalid: use letters, digits, '-' or '_'`);
|
|
14
14
|
}
|
|
15
|
+
function normalizeBaseUrl(baseUrl) {
|
|
16
|
+
return baseUrl.replace(/\/+$/, '');
|
|
17
|
+
}
|
|
15
18
|
export async function openStore(dir, protector) {
|
|
16
19
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
17
20
|
const configPath = join(dir, 'config.json');
|
|
@@ -100,7 +103,7 @@ export async function addProvider(store, input) {
|
|
|
100
103
|
requireValidId('provider', input.id);
|
|
101
104
|
if (store.config.providers.some((item) => item.id === input.id))
|
|
102
105
|
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: [] });
|
|
106
|
+
store.config.providers.push({ id: input.id, name: input.name?.trim() || input.id, protocol: input.protocol, baseUrl: normalizeBaseUrl(input.baseUrl), keys: [] });
|
|
104
107
|
try {
|
|
105
108
|
await persistConfig(store);
|
|
106
109
|
}
|
|
@@ -117,7 +120,7 @@ export async function updateProvider(store, id, input) {
|
|
|
117
120
|
if (input.protocol !== undefined)
|
|
118
121
|
provider.protocol = input.protocol;
|
|
119
122
|
if (input.baseUrl !== undefined)
|
|
120
|
-
provider.baseUrl = input.baseUrl
|
|
123
|
+
provider.baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
121
124
|
try {
|
|
122
125
|
await persistConfig(store);
|
|
123
126
|
}
|
|
@@ -133,6 +136,7 @@ export async function removeProvider(store, id) {
|
|
|
133
136
|
for (const key of provider.keys) {
|
|
134
137
|
store.vault.delete(key.vaultRef);
|
|
135
138
|
delete store.states[`${id}/${key.id}`];
|
|
139
|
+
delete store.counters[`${id}/${key.id}`];
|
|
136
140
|
}
|
|
137
141
|
await persistConfig(store);
|
|
138
142
|
await store.vault.save();
|
package/dist/tui.js
CHANGED
|
@@ -180,6 +180,8 @@ export function Tui({ client }) {
|
|
|
180
180
|
}
|
|
181
181
|
else if (row && input === 'd')
|
|
182
182
|
setConfirm({ label: `删除 key ${row.providerId}/${row.keyId}`, run: () => client.deleteKey(row.providerId, row.keyId) });
|
|
183
|
+
else if (row && input === 'D')
|
|
184
|
+
setConfirm({ label: `删除 Provider ${row.providerId}`, run: () => client.deleteProvider(row.providerId) });
|
|
183
185
|
}
|
|
184
186
|
else if (section === 'routes') {
|
|
185
187
|
const route = routes[routeCursor];
|
|
@@ -221,7 +223,7 @@ export function Tui({ client }) {
|
|
|
221
223
|
else
|
|
222
224
|
body = _jsx(DiagnosticsBody, { snapshot: snapshot });
|
|
223
225
|
const HELP = {
|
|
224
|
-
overview: 'Tab/1-4 区块 · ↑↓ 选择 · e 启用 · p 探活 · d 删除 · Enter 详情 · r 刷新 · q 退出',
|
|
226
|
+
overview: 'Tab/1-4 区块 · ↑↓ 选择 · e 启用 · p 探活 · d 删除 key · D 删除 Provider · Enter 详情 · r 刷新 · q 退出',
|
|
225
227
|
routes: 'Tab/1-4 区块 · ↑↓ 选择 · d 删除 · r 刷新 · q 退出',
|
|
226
228
|
logs: 'Tab/1-4 区块 · ↑↓ 选择 · f 过滤 · Enter 详情 · r 刷新 · q 退出',
|
|
227
229
|
diagnostics: 'Tab/1-4 区块 · t 轮换令牌 · r 刷新 · q 退出'
|
package/dist/web-ui.js
CHANGED
|
@@ -344,20 +344,27 @@ async function loadModels(){
|
|
|
344
344
|
}catch(e){error.textContent=e.message;toast(e.message);syncRouteModelHeader()}
|
|
345
345
|
}
|
|
346
346
|
async function load(){
|
|
347
|
-
try{
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
347
|
+
try{await exchange()}catch(e){error.textContent=e.message;toast(e.message);return}
|
|
348
|
+
const statusResult=await Promise.allSettled([api('/_keys/status'),api('/_keys/logs?tail='+state.logTail)]);
|
|
349
|
+
const statusResultValue=statusResult[0];
|
|
350
|
+
const logsResultValue=statusResult[1];
|
|
351
|
+
if(statusResultValue.status==='fulfilled'){
|
|
352
|
+
state.status=statusResultValue.value;
|
|
352
353
|
renderConnection();
|
|
353
|
-
renderStats();
|
|
354
354
|
syncProviderSelect($('#keyForm [name=provider]'),state.status.providers);
|
|
355
355
|
syncProviderSelect($('#routeForm [name=provider]'),state.status.providers);
|
|
356
356
|
renderProviders();
|
|
357
357
|
renderRoutes();
|
|
358
|
+
}else{error.textContent=statusResultValue.reason?.message||String(statusResultValue.reason);toast(error.textContent)}
|
|
359
|
+
if(logsResultValue.status==='fulfilled'){
|
|
360
|
+
state.logs=logsResultValue.value.logs||[];
|
|
358
361
|
renderLogs();
|
|
359
362
|
drawTraffic();
|
|
360
|
-
}
|
|
363
|
+
}else if(statusResultValue.status==='fulfilled'){
|
|
364
|
+
error.textContent=logsResultValue.reason?.message||String(logsResultValue.reason);toast(error.textContent,'warn');
|
|
365
|
+
}
|
|
366
|
+
if(statusResultValue.status==='fulfilled'&&logsResultValue.status==='fulfilled')error.textContent='';
|
|
367
|
+
if(statusResultValue.status==='fulfilled')renderStats();
|
|
361
368
|
}
|
|
362
369
|
function applyTheme(theme){document.documentElement.setAttribute('data-theme',theme);try{localStorage.setItem('khatTheme',theme)}catch(e){}const b=$('#themeToggle');b.textContent=theme==='dark'?'☀️':'🌙';b.setAttribute('aria-label',t('toggleTheme'))}
|
|
363
370
|
$('#themeToggle').onclick=()=>applyTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark');
|
|
@@ -381,7 +388,7 @@ $('#logStatus').onchange=e=>{state.logStatus=e.target.value;renderLogs()};
|
|
|
381
388
|
$('#logTail').onchange=e=>{state.logTail=e.target.value;load()};
|
|
382
389
|
$('#refresh').onclick=()=>load();
|
|
383
390
|
window.addEventListener('resize',drawTraffic);
|
|
384
|
-
load();setInterval(load,
|
|
391
|
+
load();setInterval(load,5000);
|
|
385
392
|
</script></body></html>`;
|
|
386
393
|
export class UiSessions {
|
|
387
394
|
tickets = new Map();
|