@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 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(new URL('/v1/models', provider.baseUrl), { headers, signal: AbortSignal.timeout(10_000) });
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(new URL(path, provider.baseUrl), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
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: baseUrl.replace(/\/+$/, '') }) });
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(new URL(path, provider.baseUrl), {
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.pathname !== '/')
61
- throw new Error(`Provider ${provider.id}: baseUrl must not contain a path (strip trailing /v1 etc.)`);
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(new URL(req.url, provider.baseUrl), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: new Uint8Array(body), signal: abort.signal });
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.replace(/\/+$/, '');
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
- await exchange();
349
- const results=await Promise.all([api('/_keys/status'),api('/_keys/logs?tail='+state.logTail)]);
350
- state.status=results[0];state.logs=results[1].logs||[];
351
- error.textContent='';
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
- }catch(e){error.textContent=e.message;toast(e.message)}
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,30000);
391
+ load();setInterval(load,5000);
385
392
  </script></body></html>`;
386
393
  export class UiSessions {
387
394
  tickets = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alilis/k-hat",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },