@alilis/k-hat 0.2.6 → 0.2.8

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.
@@ -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, red = unavailable, yellow = available but failing often. */
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) {
@@ -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
- 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' ? '可用' : '不可用', " \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)}` : '—'] })] });
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 }) {
@@ -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,7 +119,7 @@ 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">每 30 秒自动刷新</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
+ <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>
@@ -137,7 +138,7 @@ tbody tr:hover td{background:var(--surface-2)}
137
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>
138
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>
139
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>
140
- <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>
141
142
  <div id="providers"></div>
142
143
  </section>
143
144
  <section class="card">
@@ -161,8 +162,8 @@ const NS='http://www.w3.org/2000/svg';
161
162
  const pad2=n=>String(n).padStart(2,'0');
162
163
  const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all',routeModels:[],routeSelected:new Set(),routeFilter:'',routeOpen:false,routeProvider:''};
163
164
  const I18N={
164
- zh:{title:'khat 密钥管理',toggleTheme:'切换深色 / 浅色模式',toggleLang:'切换语言',keyFilterAria:'筛选 Key',statusFilterAria:'状态筛选',resultFilterAria:'结果筛选',logCountAria:'日志条数',autoRefresh:'每 30 秒自动刷新',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:'仅可用',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:'可用',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:'请求成功 / 失败趋势'},
165
- 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 30s',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',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',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'}
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'}
166
167
  };
167
168
  let lang=(()=>{try{return localStorage.getItem('khatLang')==='en'?'en':'zh'}catch(e){return 'zh'}})();
168
169
  function t(key){return (I18N[lang]&&I18N[lang][key])||I18N.zh[key]||key}
@@ -178,7 +179,19 @@ function formatTs(ts){const d=new Date(ts);if(isNaN(d.getTime()))return String(t
178
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})}
179
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}
180
181
  function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message;toast(e.message)}}}
181
- function statusCell(ok,text,title){const td=document.createElement('td');const sp=document.createElement('span');sp.className='pill pill-status '+(ok?'pill-ok':'pill-bad');sp.textContent=text;if(title)sp.title=title;td.append(sp);return td}
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
+ }
182
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}
183
196
  function renderConnection(){if(!state.status)return;const c=$('#connection');c.replaceChildren(Object.assign(document.createElement('i'),{className:'dot'}),document.createTextNode(' '+state.status.listen))}
184
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):'—'}
@@ -211,7 +224,7 @@ function renderProviders(){
211
224
  if(k.secret){const sk=document.createElement('span');sk.className='key-secret';sk.textContent=k.secret;kt.append(sk)}
212
225
  tr.append(kt);
213
226
  tr.append(cell(k.weight,'num'));
214
- tr.append(statusCell(k.enabled!==false&&k.status==='available',k.enabled===false?t('disabled'):(k.status==='available'?t('avail'):t('unavail')),k.lastError?errText(k)+(k.lastError.at?' · '+t('errOccurredAt')+' '+k.lastError.at:''):''));
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:''):''));
215
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'));
216
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;
217
230
  tr.append(et);
@@ -239,7 +252,13 @@ function renderRoutes(){
239
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}
240
253
  for(const rt of rs){
241
254
  const tr=document.createElement('tr');
242
- tr.append(cell(rt.model,'mono-cell'),cell(rt.upstreamModel||rt.model,'mono-cell'),cell(rt.provider),statusCell(rt.enabled!==false,rt.enabled!==false?t('avail'):t('unavail')));
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));
243
262
  const d=document.createElement('td');
244
263
  const toggle=rt.enabled!==false?'disable':'enable';
245
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()})));
@@ -295,7 +314,7 @@ function renderLogs(){
295
314
  for(const e of rows){
296
315
  const r=document.createElement('tr');
297
316
  r.append(cell(formatTs(e.ts),'mono-cell'));
298
- 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)));
299
318
  r.append(cell(e.provider,'mono-cell'),cell(e.key,'mono-cell'),cell(e.model,'mono-cell'));
300
319
  r.append(cell((e.durationMs==null?0:e.durationMs)+'ms','num'));
301
320
  r.append(cell((e.tokensIn||0)+' / '+(e.tokensOut||0),'num'));
@@ -399,13 +418,25 @@ $('#refresh').onclick=()=>load();
399
418
  window.addEventListener('resize',drawTraffic);
400
419
  load();setInterval(load,5000);
401
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. */
402
422
  export class UiSessions {
423
+ ttlMs;
403
424
  tickets = new Map();
404
- sessions = new Set();
425
+ sessions = new Map();
426
+ constructor(ttlMs = 3_600_000) {
427
+ this.ttlMs = ttlMs;
428
+ }
405
429
  createTicket() { const t = randomBytes(32).toString('base64url'); this.tickets.set(t, Date.now() + 60000); return t; }
406
430
  exchange(t) { const expires = this.tickets.get(t); this.tickets.delete(t); if (expires === undefined || expires < Date.now())
407
- return undefined; const s = randomBytes(32).toString('base64url'); this.sessions.add(s); return s; }
408
- valid(s) { return s !== undefined && this.sessions.has(s); }
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; }
409
440
  }
410
441
  export function serveUi(req, res) { const path = new URL(req.url ?? '/', 'http://localhost').pathname; if (path !== '/_keys/ui' && path !== '/_keys/ui/')
411
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.6",
3
+ "version": "0.2.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,7 +8,7 @@
8
8
  "license": "MIT",
9
9
  "type": "module",
10
10
  "bin": {
11
- "khat": "dist/cli.js"
11
+ "khat": "dist/cli/cli.js"
12
12
  },
13
13
  "files": [
14
14
  "dist"
@@ -36,9 +36,10 @@
36
36
  "scripts": {
37
37
  "build": "tsc -p tsconfig.json",
38
38
  "prepare": "npm run build",
39
- "start": "node dist/cli.js start",
40
- "khat": "node dist/cli.js",
41
- "test": "npm run build && node --test test/**/*.test.js"
39
+ "start": "node dist/cli/cli.js start",
40
+ "khat": "node dist/cli/cli.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",
package/dist/server.js DELETED
@@ -1,238 +0,0 @@
1
- import { createServer } from 'node:http';
2
- import { watch } from 'node:fs';
3
- import { readJsonFile, saveJsonAtomic, validateConfig, providerUrl, defaultTimeouts } from './config.js';
4
- import { LogWriter } from './logger.js';
5
- import { join, basename } from 'node:path';
6
- import { findRoute, routeUpstreamModel, resolveProvider, isRouteDisabled } 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 route = findRoute(options.config, parsed.model);
105
- const provider = resolveProvider(options.config, parsed.model);
106
- if (!provider) {
107
- if (isRouteDisabled(options.config, parsed.model))
108
- return json(res, 403, { error: { message: `Route disabled for model: ${parsed.model}` } });
109
- return json(res, 404, { error: { message: `No route for model: ${parsed.model}` } });
110
- }
111
- if (provider.protocol !== protocol)
112
- return json(res, 400, { error: { message: `Model ${parsed.model} resolves to a ${provider.protocol} provider, but ${req.url} speaks ${protocol}` } });
113
- const upstreamBody = JSON.stringify({ ...parsed, model: routeUpstreamModel(route) });
114
- const tried = new Set();
115
- while (true) {
116
- const key = selector.select(provider.id, provider.keys.filter((item) => !tried.has(item.id)), states);
117
- if (!key)
118
- 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' }) })) } });
119
- tried.add(key.id);
120
- const secret = currentSecret(key.vaultRef);
121
- if (secret === undefined)
122
- continue;
123
- const abort = new AbortController();
124
- const clientGone = () => abort.abort(new Error('client disconnected'));
125
- res.on('close', clientGone);
126
- const headerTimer = setTimeout(() => upstreamAbort(abort, 'upstream response header timeout'), timeouts().headerMs);
127
- let upstream;
128
- try {
129
- upstream = await fetch(providerUrl(provider.baseUrl, req.url), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: upstreamBody, signal: abort.signal });
130
- }
131
- catch (error) {
132
- if (error?.upstreamTimeout)
133
- error.statusCode = 504;
134
- throw error;
135
- }
136
- finally {
137
- clearTimeout(headerTimer);
138
- }
139
- if (RETRYABLE.has(upstream.status)) {
140
- await markUnavailable(provider.id, key.id, upstream.status);
141
- if (tried.size < provider.keys.length) {
142
- res.off('close', clientGone);
143
- continue;
144
- }
145
- }
146
- const startedAt = Date.now();
147
- let firstByteAt;
148
- let bytes = 0;
149
- let sseRemainder = '';
150
- let tokensIn = 0;
151
- let tokensOut = 0;
152
- const responseHeaders = {};
153
- upstream.headers.forEach((value, name) => { if (!HOP_BY_HOP.has(name))
154
- responseHeaders[name] = value; });
155
- res.writeHead(upstream.status, responseHeaders);
156
- if (upstream.body) {
157
- const idleTimer = setTimeout(() => upstreamAbort(abort, 'upstream stream idle timeout'), timeouts().streamIdleMs);
158
- try {
159
- for await (const chunk of upstream.body) {
160
- const buffer = Buffer.from(chunk);
161
- if (firstByteAt === undefined)
162
- firstByteAt = Date.now();
163
- bytes += buffer.length;
164
- const combined = sseRemainder + buffer.toString('utf8');
165
- const lastNewline = combined.lastIndexOf('\n');
166
- if (lastNewline >= 0) {
167
- const usage = scanUsage(protocol, combined.slice(0, lastNewline + 1));
168
- tokensIn += usage.tokensIn;
169
- tokensOut += usage.tokensOut;
170
- sseRemainder = combined.slice(lastNewline + 1);
171
- }
172
- else
173
- sseRemainder = combined;
174
- res.write(buffer);
175
- idleTimer.refresh();
176
- }
177
- const usage = scanUsage(protocol, sseRemainder);
178
- tokensIn += usage.tokensIn;
179
- tokensOut += usage.tokensOut;
180
- }
181
- catch (error) {
182
- if (error?.upstreamTimeout)
183
- res.destroy(error);
184
- throw error;
185
- }
186
- finally {
187
- clearTimeout(idleTimer);
188
- }
189
- }
190
- const durationMs = Date.now() - startedAt;
191
- const ttfbMs = (firstByteAt ?? Date.now()) - startedAt;
192
- const keyRef = `${provider.id}/${key.id}`;
193
- options.store?.recordCounter(keyRef, { requests: 1, failed: upstream.ok ? 0 : 1, bytesOut: bytes, tokensIn, tokensOut });
194
- try {
195
- 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 });
196
- }
197
- catch (error) {
198
- console.error(`[khat] failed to write request log: ${error?.message ?? error}`);
199
- }
200
- res.end();
201
- res.off('close', clientGone);
202
- return;
203
- }
204
- }
205
- catch (error) {
206
- if (!res.headersSent)
207
- json(res, error.statusCode ?? 502, { error: { message: error.message ?? 'Proxy error' } });
208
- else
209
- res.destroy(error);
210
- }
211
- });
212
- const configWatcher = options.store ? watch(options.store.dir, (_event, filename) => {
213
- if (filename?.toString() !== basename(options.store.configPath))
214
- return;
215
- void (async () => {
216
- try {
217
- const next = await readJsonFile(options.store.configPath);
218
- if (next === undefined)
219
- return;
220
- const validated = validateConfig(next);
221
- Object.assign(options.config, validated);
222
- for (const key of Object.keys(secrets))
223
- delete secrets[key];
224
- for (const provider of validated.providers)
225
- for (const key of provider.keys) {
226
- const secret = options.store?.vault.get(key.vaultRef);
227
- if (secret !== undefined)
228
- secrets[key.vaultRef] = secret;
229
- }
230
- }
231
- catch (error) {
232
- console.error(`[khat] ignored invalid external config update: ${error?.message ?? error}`);
233
- }
234
- })();
235
- }) : undefined;
236
- server.once('close', () => configWatcher?.close());
237
- return server;
238
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes