@alilis/k-hat 0.2.7 → 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.
package/README.md CHANGED
@@ -1,82 +1,19 @@
1
- # K-Hat(khat)
1
+ ## 停止后台代理
2
2
 
3
- 本地 API key 代理管理器,统一管理多个 AI 服务商的密钥并自动轮转。
4
-
5
- 把 Codex、OpenCode、ZCode 等 Agent Tool 的 API 端点统一指向 khat,之后更换任何 key 都不必再碰这些工具的配置。当一个 key 触发限流(429)、鉴权失败(401)或余额耗尽(402)时,代理自动切换到同 Provider 的其他 key,你的编码工作流不中断。
6
-
7
- ## 特性
8
-
9
- - **单端口双协议**:一个 HTTP 入口同时承载 OpenAI 族与 Anthropic 族协议,按路径区分
10
- - **模型路由**:按请求中的 `model` 字段路由到目标 Provider,支持 `provider/model` 显式消歧
11
- - **多 key 加权轮询**:一个 Provider 可配多个 key,按权重平滑分配请求
12
- - **自动切换**:上游返回 429 / 401 / 402 时标记 key 不可用并换下一个 key 重发(仅首字节前)
13
- - **加密密钥库**:全部机密(key 明文、访问令牌)存放在 `vault.json` 的加密 payload 中,payload 使用 AES-256-GCM 加密,32 字节主密钥由操作系统绑定(Windows DPAPI)保护,配置文件零机密可安全备份
14
- - **流式透传**:SSE 逐 chunk 透传,不缓冲、不改写响应内容;客户端断开级联取消上游请求
15
- - **后台守护与自愈**:`khat start` 在后台启动 supervisor,崩溃后指数退避重启;`khat stop` 优雅停止,`--foreground` 用于诊断
16
- - **可观测性**:零机密 JSONL 请求日志、按 key 的请求/失败/字节/Token 计数器、`khat log` 与 `/_keys/logs`
17
- - **本地管理页**:`khat ui` 以 60 秒单次票据打开内嵌 UI,查看状态、日志并恢复不可用 key
18
- - **零运行时依赖**:纯 Node.js/TypeScript,仅用 Node 内置模块
19
-
20
- ## 安装
21
-
22
- **方式一:从 npm 安装(推荐)**
23
-
24
- ```bash
25
- npm install -g @alilis/k-hat
26
- ```
27
-
28
- 之后任意目录都能直接使用下文的 `khat <命令>`。卸载用 `npm uninstall -g @alilis/k-hat`。
29
-
30
- > Windows 下若提示找不到 `khat`,确认 `%APPDATA%\npm` 在 PATH 中(重开终端后生效)。
31
-
32
- **方式二:从源码运行**
33
-
34
- ```bash
35
- git clone https://github.com/aweirdocc/k-hat
36
- cd k-hat
37
- npm install # 安装依赖的同时会自动编译 TypeScript(prepare 钩子)
3
+ khat stop
38
4
  ```
39
5
 
40
- 执行 `npm link` 注册全局命令,效果与方式一相同;或者不改动全局环境,停留在仓库目录内,把下文所有的 `khat <命令>` 换成 `npm run khat -- <命令>`:
41
-
42
- ```bash
43
- npm run khat -- status
44
- npm run khat -- tui
45
- ```
6
+ 然后在你的工具中把 `base_url` 指向 `http://127.0.0.1:8787/v1`(或 `http://127.0.0.1:8787`,视工具而定),认证令牌填 `khat init` 生成的访问令牌。
46
7
 
47
- ## 快速开始
8
+ ### 服务化部署(M7)
48
9
 
49
10
  ```bash
50
- # 1. 首次初始化:生成加密密钥库 + 访问令牌 + 默认配置
51
- khat init
52
- # 输出访问令牌(仅此一次,也可用 khat token show 查看)
53
-
54
- # 2. 添加 Provider
55
- khat provider add deepseek --base-url https://api.deepseek.com --name DeepSeek --protocol openai
56
-
57
- # 3. 添加 key(省略 --value 时交互式提示输入;--weight 为该 key 的轮询权重,默认 1)
58
- khat key add deepseek main --weight 3
59
-
60
- # 4. 添加路由:模型名 -> Provider
61
- khat route add deepseek-v4-pro deepseek
62
-
63
- # 5. 后台启动代理(管理变更命令也会在需要时自动启动它)
64
- khat start
65
-
66
- # 6. 查看状态
67
- khat status
68
-
69
- # 7. 打开本地管理页
70
- khat ui
71
-
72
- # 或在终端中打开交互式管理界面
73
- khat tui
74
-
75
- # 8. 停止后台代理
76
- khat stop
11
+ khat service install # 注册开机自启(Windows 计划任务 / macOS launchd / Linux systemd --user)
12
+ khat service uninstall # 取消开机自启
13
+ khat service status # 查看服务注册状态与守护进程运行状态
77
14
  ```
78
15
 
79
- 然后在你的工具中把 `base_url` 指向 `http://127.0.0.1:8787/v1`(或 `http://127.0.0.1:8787`,视工具而定),认证令牌填 `khat init` 生成的访问令牌。
16
+ > **注意**:service 安装时会固定 `KHAT_HOME` 为安装时解析的数据目录;`khat stop` 不会卸载服务,下次登录仍自动启动;Windows 上未采用 NSSM(因用户级 DPAPI vault 无法以 LocalSystem 运行)。
80
17
 
81
18
  ## CLI 命令
82
19
 
@@ -97,6 +34,28 @@ khat stop
97
34
 
98
35
  > Provider、key 和路由的新增、更新、删除与恢复等管理变更通过本地 Admin API 执行;若 daemon 尚未运行,命令会自动启动它。
99
36
 
37
+ ### 模型列表接口
38
+
39
+ 代理提供带访问令牌认证的只读模型列表接口:
40
+
41
+ ```bash
42
+ TOKEN=$(khat token show)
43
+ curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8787/models
44
+ ```
45
+
46
+ `GET /models` 和兼容 OpenAI 客户端的 `GET /v1/models` 返回当前配置中启用的 `routes`,不会请求上游 Provider,也不会返回 `upstreamModel`:
47
+
48
+ ```json
49
+ {
50
+ "object": "list",
51
+ "data": [
52
+ { "id": "deepseek-chat", "object": "model", "created": 0, "owned_by": "deepseek" }
53
+ ]
54
+ }
55
+ ```
56
+
57
+ 未配置路由时 `data` 为空;禁用路由不会出现在列表中。请求必须携带 `Authorization: Bearer <access-token>`,否则返回 HTTP 401。
58
+
100
59
  ### 访问令牌
101
60
 
102
61
  | 命令 | 说明 |
@@ -124,7 +83,7 @@ khat stop
124
83
  | `khat key list [<provider>]` | 列出 key(默认脱敏,仅显示尾 4 位) |
125
84
  | `khat key remove <provider> <id>` | 删除 key |
126
85
  | `khat key enable <provider> <id>` | 将被标记不可用的 key 恢复为可用 |
127
- | `khat key test <provider> <id> [--model <m>]` | 直连配置的上游发送一次最小请求探活;成功后恢复 key,不会仅因探测返回 401/402/429 就标记 key 不可用 |
86
+ | `khat key test <provider> <id> [--model <m>]` | 直连配置的上游发送一次最小请求探活;成功后自动恢复 key,不会仅因探测返回 401/402/429 就标记 key 不可用 |
128
87
 
129
88
  ### 路由管理
130
89
 
@@ -140,7 +99,7 @@ khat stop
140
99
  | 变量 | 说明 |
141
100
  |---|---|
142
101
  | `KHAT_HOME` | 数据目录(默认 `~/.khat`) |
143
- | `KHAT_ACCESS_TOKEN` | 覆盖密钥库中的访问令牌(主要供测试) |
102
+ | `KHAT_ACCESS_TOKEN` | 覆盖密钥库中的访问密钥(主要供测试) |
144
103
 
145
104
  ## 常见异常与处理
146
105
 
@@ -162,68 +121,7 @@ khat stop
162
121
  | `daemon started but health check failed` | 后台 daemon 启动异常 | `khat start --foreground` 前台运行观察日志 |
163
122
  | `--weight must be a positive integer` | 权重不是正整数 | 传入 ≥1 的整数 |
164
123
  | `unknown provider: <x>` / `unknown key: <x/y>` | 引用了不存在的 Provider / Key | 用 `khat provider list` / `khat key list` 核对 |
165
- | `empty value` | 交互输入 key 值时未输入 | 重新输入,或用 `--value <v>` 提供 |
166
- | TTY `key add` 交互报错 | 无交互终端且未传 `--value` | `--value <v>` 提供 |
167
- | `Ticket is invalid or expired` | UI 票据已过期或已使用 | 重新运行 `khat ui` |
168
- | `khat is not running` | 需要 daemon 的命令但未启动 | `khat start` |
169
-
170
- ### 代理返回的入口错误
171
-
172
- | 状态码 | 含义 | 处理 |
173
- |---|---|---|
174
- | 400 | 入口路径与 Provider 协议不匹配,或请求体非法 | 检查工具配置的入口路径与 `base_url` |
175
- | 401 | 访问令牌无效 | 用 `khat token show` 核对工具里配置的令牌 |
176
- | 404 | 请求的模型没有匹配的路由 | `khat route add <model> <provider>` |
177
- | 502 | 上游返回错误(如 key 探活失败) | 查看 `khat log` 与 key 状态 |
178
-
179
- ### 使用注意事项
180
-
181
- - **变更命令会自动拉起 daemon**:`provider / key / route` 等管理命令通过本地 Admin API 执行,若后台服务未运行会自动启动(单写者设计),无需手动 `khat start`。
182
- - **UI 票据单次有效**:`khat ui` 签发的票据 60 秒有效、只能交换一次会话;管理页保持打开不受影响,重新打开需再次 `khat ui`。
183
- - **密钥库加密描述**:`vault.json` 是合法 JSON 信封,机密载荷经 AES-256-GCM 加密,32 字节主密钥由 Windows DPAPI 保护;文件本身不是整体二进制密文。
184
-
185
- ## 协议支持
186
-
187
- | 入口路径 | 协议 | 认证头 | 服务的工具 |
188
- |---|---|---|---|
189
- | `/v1/chat/completions` | OpenAI Chat | `Authorization: Bearer` | OpenCode、Cursor(部分) |
190
- | `/v1/responses` | OpenAI Responses | `Authorization: Bearer` | Codex |
191
- | `/v1/messages` | Anthropic Messages | `x-api-key` + `anthropic-version` | ZCode |
192
-
193
- 采用**同族直通**策略:OpenAI 入口转发到 OpenAI 上游,Anthropic 入口转发到 Anthropic 上游,只重写认证头与目标 URL,不改写请求体。入口路径与 Provider 协议不匹配时返回 400。
194
-
195
- ## 数据安全
196
-
197
- - **密钥库加密落盘**:`vault.json` 是 JSON 信封;其中 `payload` 使用 AES-256-GCM 加密,32 字节主密钥由 Windows DPAPI(CurrentUser 作用域)保护,key 明文仅存在于守护进程内存
198
- - **配置零机密**:`config.json` 只存 `vaultRef` 引用,可放心进 Git/网盘备份
199
- - **日志零机密**:不记录 key 明文、请求/响应体、认证头;CLI 展示 key 默认脱敏
200
- - **入口双重防护**:所有请求校验 256-bit 访问令牌;默认只绑定 loopback,不对局域网暴露
201
- - **上游强制 HTTPS**:证书校验,不提供禁用开关(仅 loopback 主机允许明文 http)
202
-
203
- ## 项目结构
204
-
205
- ```
206
- src/
207
- ├── server.ts # HTTP 服务与转发器(双协议、认证头重写、超时、重试)
208
- ├── router.ts # 模型名 -> Provider 路由
209
- ├── selector.ts # 平滑加权轮询
210
- ├── store.ts # config/state/vault 存储层 + CLI 变更操作
211
- ├── vault.ts # AES-256-GCM 加密密钥库
212
- ├── config.ts # 配置加载、校验、原子写入
213
- ├── dpapi.ts # Windows DPAPI 主密钥保护
214
- ├── types.ts # 类型定义
215
- └── cli.ts # CLI 入口
216
- test/ # 自动化测试(node:test)
217
- docs/ # 需求、可行性、概要设计、ADR
218
- ```
219
-
220
- ## 开发
221
-
222
- ```bash
223
- npm test # 构建 + 运行全部测试
224
- npm run build # 仅编译 TypeScript
225
- npm start # 启动代理
226
- npm run khat -- status # 源码运行任意 CLI 子命令
227
- ```
228
-
229
- 当前状态与设计决策见 [CONTEXT.md](./CONTEXT.md) 与 [docs/](./docs/)。
124
+ | `khat service is not installed` | service 注册不存在 | `khat service install` |
125
+ | `could not register the scheduled task` | Windows 计划任务注册失败 | 检查任务调度器权限,运行 `schtasks /Query /TN khat` 确认 |
126
+ | `could not load the launch agent` | macOS launchd 加载失败 | 检查 `~/Library/LaunchAgents/local.khat.plist` 是否被手动删除,运行 `launchctl list local.khat` |
127
+ | `could not enable the service` | Linux systemd 启用失败 | 检查 `~/.config/systemd/user/khat.service` 是否被删除,运行 `systemctl --user is-enabled khat` |
@@ -6,14 +6,15 @@ 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, providerUrl, 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';
9
+ import { saveJsonAtomic, providerUrl, defaultConfig } from '../core/config.js';
10
+ import { createKhatServer } from '../network/server.js';
11
+ import { openStore, maskSecret } from '../data/store.js';
12
+ import { Vault, generateAccessToken, ACCESS_TOKEN_REF } from '../data/vault.js';
13
+ import { createKeyProtector } from '../data/key-protector.js';
14
+ import { exportPortable, importPortable } from '../data/portable-vault.js';
15
+ import { LogWriter } from '../core/logger.js';
16
+ import { runDoctor as inspectDoctor, formatDoctorSuggestion } from '../doctor.js';
17
+ import { createServiceManager } from '../service.js';
17
18
  const dataDir = process.env.KHAT_HOME ?? join(homedir(), '.khat');
18
19
  const configPath = join(dataDir, 'config.json');
19
20
  const statePath = join(dataDir, 'state.json');
@@ -30,6 +31,9 @@ Setup
30
31
  start [--foreground] [--port <n>] start the proxy as a background daemon (or foreground with --foreground);
31
32
  --port <n> changes the persisted listen port
32
33
  stop stop the background daemon
34
+ service install register khat to auto-start at logon (Task Scheduler / launchd / systemd --user)
35
+ service uninstall remove the auto-start registration
36
+ service status show the service registration and daemon state
33
37
  status show providers, keys, routes and key health
34
38
  doctor detect local Agent Tools and show connection guidance
35
39
  log [--tail <n>] show recent masked proxy request logs
@@ -250,6 +254,28 @@ async function daemonRunning() {
250
254
  return false;
251
255
  }
252
256
  }
257
+ async function readDaemonPid() {
258
+ if (!(await daemonRunning()))
259
+ return undefined;
260
+ try {
261
+ return Number.parseInt((await readFile(pidPath, 'utf8')).trim(), 10);
262
+ }
263
+ catch {
264
+ return undefined;
265
+ }
266
+ }
267
+ function printServiceStatus(service, daemonPid) {
268
+ if (!service.installed) {
269
+ console.log('khat service is not installed (run khat service install)');
270
+ return;
271
+ }
272
+ const enabled = service.enabled === null ? 'unknown' : service.enabled ? 'on' : 'off';
273
+ const running = service.running === null ? 'unknown' : service.running ? 'yes' : 'no';
274
+ console.log('khat service');
275
+ console.log(` auto-start: ${enabled}`);
276
+ console.log(` running: ${running}${service.detail ? ` (${service.detail})` : ''}`);
277
+ console.log(` daemon: ${daemonPid !== undefined ? `running (pid ${daemonPid})` : 'not running'}`);
278
+ }
253
279
  async function runForeground() {
254
280
  const store = await openInitializedStore();
255
281
  const secrets = {};
@@ -375,7 +401,7 @@ async function runTui() {
375
401
  if (!token)
376
402
  throw new Error('no access token found; run khat init');
377
403
  await ensureDaemon();
378
- const { runTui: startTui } = await import('./tui-main.js');
404
+ const { runTui: startTui } = await import('../ui/tui/tui-main.js');
379
405
  await startTui(`http://${store.config.bind}:${store.config.port}`, token);
380
406
  }
381
407
  async function runUi() {
@@ -504,6 +530,25 @@ try {
504
530
  case 'stop':
505
531
  await runStop();
506
532
  break;
533
+ case 'service': {
534
+ const action = positionals[0];
535
+ const manager = createServiceManager(process.platform, { dataDir });
536
+ if (action === 'install') {
537
+ await openInitializedStore();
538
+ console.log(await manager.install({ startNow: !(await daemonRunning()) }));
539
+ }
540
+ else if (action === 'uninstall') {
541
+ console.log(await manager.uninstall());
542
+ }
543
+ else if (action === 'status') {
544
+ printServiceStatus(await manager.status(), await readDaemonPid());
545
+ }
546
+ else {
547
+ console.error('usage: khat service install | uninstall | status');
548
+ process.exitCode = 1;
549
+ }
550
+ break;
551
+ }
507
552
  case 'status':
508
553
  printStatus(await openInitializedStore());
509
554
  break;
@@ -1,4 +1,4 @@
1
- import { providerUrl } from './config.js';
1
+ import { providerUrl } from '../core/config.js';
2
2
  const ANTHROPIC_VERSION = '2023-06-01';
3
3
  /** Minimal one-token ping shared by the Admin probe endpoint and the background prober. */
4
4
  export async function probeKey(provider, model, secret) {
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
3
- import { dpapiProtector } from './dpapi.js';
3
+ import { dpapiProtector } from '../data/dpapi.js';
4
4
  const SERVICE = 'khat-vault';
5
5
  const ACCOUNT = 'khat';
6
6
  function run(command, args, input = '') {
@@ -1,8 +1,8 @@
1
1
  import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
2
2
  import { access, mkdir, readFile, rename, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { saveJsonAtomic, validateConfig, defaultConfig } from './config.js';
5
- import { Vault } from './vault.js';
4
+ import { saveJsonAtomic, validateConfig, defaultConfig } from '../core/config.js';
5
+ import { Vault } from '../data/vault.js';
6
6
  const FORMAT = 'khat-vault-export';
7
7
  const VERSION = 1;
8
8
  const AAD = 'khat-vault-export-v1';
@@ -1,7 +1,7 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { readJsonFile, saveJsonAtomic, loadState, validateConfig, defaultConfig } from './config.js';
4
- import { Vault } from './vault.js';
3
+ import { readJsonFile, saveJsonAtomic, loadState, validateConfig, defaultConfig } from '../core/config.js';
4
+ import { Vault } from '../data/vault.js';
5
5
  /** Ids appear in `provider/model` routing syntax and in vault refs, so they cannot contain `/`. */
6
6
  const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
7
7
  export function maskSecret(value) {
@@ -1,6 +1,6 @@
1
1
  import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { saveJsonAtomic } from './config.js';
3
+ import { saveJsonAtomic } from '../core/config.js';
4
4
  const VAULT_VERSION = 1;
5
5
  const AAD = 'khat-vault-v1';
6
6
  // Releases before the kay->khat rename wrote their payload under this AAD; files saved that way stay readable and are rewritten under the current AAD on their next save().
@@ -1,13 +1,13 @@
1
- import { addProvider, updateProvider, removeProvider, addKey, updateKey, removeKey, enableKey, setKeyEnabled, addRoute, updateRoute, setRouteEnabled, removeRoute, maskSecret } from './store.js';
2
- import { LogWriter } from './logger.js';
1
+ import { addProvider, updateProvider, removeProvider, addKey, updateKey, removeKey, enableKey, setKeyEnabled, addRoute, updateRoute, setRouteEnabled, removeRoute, maskSecret } from '../data/store.js';
2
+ import { LogWriter } from '../core/logger.js';
3
3
  import { join } from 'node:path';
4
- import { UiSessions, serveUi } from './web-ui.js';
5
- import { ACCESS_TOKEN_REF, generateAccessToken } from './vault.js';
6
- import { providerUrl } from './config.js';
7
- import { findRoute, routeUpstreamModel } from './router.js';
4
+ import { UiSessions, serveUi } from '../ui/web-ui.js';
5
+ import { ACCESS_TOKEN_REF, generateAccessToken } from '../data/vault.js';
6
+ import { providerUrl } from '../core/config.js';
7
+ import { findRoute, routeUpstreamModel } from '../core/router.js';
8
8
  import { keyBlocked } from './selector.js';
9
- import { probeKey } from './probe.js';
10
- import { readExport, applyImport } from './portable-vault.js';
9
+ import { probeKey } from '../core/probe.js';
10
+ import { readExport, applyImport } from '../data/portable-vault.js';
11
11
  // Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
12
12
  // only from loopback and behind the proxy access token, so a local non-loopback
13
13
  // client or a process without the token cannot mutate config/state/vault.
@@ -1,14 +1,14 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { watch } from 'node:fs';
3
- import { readJsonFile, saveJsonAtomic, validateConfig, providerUrl, defaultTimeouts } from './config.js';
4
- import { LogWriter } from './logger.js';
3
+ import { readJsonFile, saveJsonAtomic, validateConfig, providerUrl, defaultTimeouts } from '../core/config.js';
4
+ import { LogWriter } from '../core/logger.js';
5
5
  import { join, basename } from 'node:path';
6
- import { findRoute, routeUpstreamModel, resolveProvider, isRouteDisabled } from './router.js';
6
+ import { findRoute, routeUpstreamModel, resolveProvider, isRouteDisabled } from '../core/router.js';
7
7
  import { WeightedSelector } from './selector.js';
8
- import { handleAdmin } from './admin.js';
9
- import { enableKey } from './store.js';
10
- import { probeKey } from './probe.js';
11
- import { ACCESS_TOKEN_REF } from './vault.js';
8
+ import { handleAdmin } from '../network/admin.js';
9
+ import { enableKey } from '../data/store.js';
10
+ import { probeKey } from '../core/probe.js';
11
+ import { ACCESS_TOKEN_REF } from '../data/vault.js';
12
12
  const RETRYABLE = new Set([401, 402, 429]);
13
13
  const HOP_BY_HOP = new Set(['content-length', 'transfer-encoding', 'connection']);
14
14
  const ANTHROPIC_VERSION = '2023-06-01';
@@ -27,6 +27,13 @@ const ENDPOINTS = {
27
27
  '/v1/responses': 'openai',
28
28
  '/v1/messages': 'anthropic'
29
29
  };
30
+ function modelList(config) {
31
+ const seen = new Set();
32
+ const data = config.routes
33
+ .filter((route) => route.enabled !== false && !seen.has(route.model) && seen.add(route.model))
34
+ .map((route) => ({ id: route.model, object: 'model', created: 0, owned_by: route.provider }));
35
+ return { object: 'list', data };
36
+ }
30
37
  /** Rewrite the client-facing auth into the upstream auth convention for the target protocol. */
31
38
  function upstreamHeaders(protocol, secret, accept) {
32
39
  if (protocol === 'anthropic')
@@ -132,11 +139,14 @@ export function createKhatServer(options) {
132
139
  return;
133
140
  }
134
141
  const currentAccessToken = options.store?.vault.get(ACCESS_TOKEN_REF) ?? options.accessToken;
135
- if (currentAccessToken && req.headers.authorization !== `Bearer ${currentAccessToken}`)
142
+ if (!currentAccessToken || req.headers.authorization !== `Bearer ${currentAccessToken}`)
136
143
  return json(res, 401, { error: { message: 'Unauthorized' } });
137
- if (req.method !== 'POST' || !(req.url in ENDPOINTS))
144
+ const pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
145
+ if (req.method === 'GET' && (pathname === '/models' || pathname === '/v1/models'))
146
+ return json(res, 200, modelList(options.config));
147
+ if (req.method !== 'POST' || !(pathname in ENDPOINTS))
138
148
  return json(res, 404, { error: { message: 'Not found' } });
139
- const protocol = ENDPOINTS[req.url];
149
+ const protocol = ENDPOINTS[pathname];
140
150
  const body = await readBody(req, options.config.requestBodyLimitMB * 1024 * 1024);
141
151
  let parsed;
142
152
  try {
@@ -180,7 +190,7 @@ export function createKhatServer(options) {
180
190
  const headerTimer = setTimeout(() => upstreamAbort(abort, 'upstream response header timeout'), timeouts().headerMs);
181
191
  let upstream;
182
192
  try {
183
- upstream = await fetch(providerUrl(provider.baseUrl, req.url), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: upstreamBody, signal: abort.signal });
193
+ upstream = await fetch(providerUrl(provider.baseUrl, pathname), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: upstreamBody, signal: abort.signal });
184
194
  }
185
195
  catch (error) {
186
196
  if (error?.upstreamTimeout)
@@ -0,0 +1,283 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { access, mkdir, unlink, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { promisify } from 'node:util';
7
+ const execFileAsync = promisify(execFile);
8
+ const WINDOWS_TASK_NAME = 'khat';
9
+ const LAUNCHD_LABEL = 'local.khat';
10
+ const SYSTEMD_UNIT = 'khat';
11
+ async function defaultRunner(command, args) {
12
+ try {
13
+ const { stdout, stderr } = await execFileAsync(command, args, { windowsHide: true, timeout: 30_000 });
14
+ return { code: 0, stdout: String(stdout), stderr: String(stderr) };
15
+ }
16
+ catch (error) {
17
+ if (error?.code === 'ENOENT')
18
+ return { code: null, stdout: '', stderr: `${command} is not available` };
19
+ return { code: typeof error?.code === 'number' ? error.code : null, stdout: String(error?.stdout ?? ''), stderr: String(error?.stderr ?? error?.message ?? 'command failed') };
20
+ }
21
+ }
22
+ async function fileExists(path) {
23
+ try {
24
+ await access(path);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function xmlEscape(value) {
32
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
33
+ }
34
+ function resolveOptions(options) {
35
+ const home = options.homeDir ?? homedir();
36
+ return {
37
+ runner: options.run ?? defaultRunner,
38
+ homeDir: home,
39
+ dataDir: options.dataDir ?? process.env.KHAT_HOME ?? join(home, '.khat'),
40
+ distDir: options.distDir ?? dirname(fileURLToPath(new URL('./supervisor.js', import.meta.url))),
41
+ execPath: options.execPath ?? process.execPath,
42
+ userId: options.userId ?? (process.env.USERDOMAIN && process.env.USERNAME ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : undefined),
43
+ uid: options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : undefined)
44
+ };
45
+ }
46
+ function commandFailure(result, action) {
47
+ return `${action}: ${(result.stderr || result.stdout).trim() || `command exited with code ${result.code}`}`;
48
+ }
49
+ export function buildTaskXml(options) {
50
+ const triggerUser = options.userId ? `\n <UserId>${xmlEscape(options.userId)}</UserId>` : '';
51
+ return `<?xml version="1.0" encoding="UTF-16"?>
52
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
53
+ <RegistrationInfo>
54
+ <Description>Khat proxy supervisor; starts the khat daemon when you log on</Description>
55
+ </RegistrationInfo>
56
+ <Triggers>
57
+ <LogonTrigger>
58
+ <Enabled>true</Enabled>${triggerUser}
59
+ </LogonTrigger>
60
+ </Triggers>
61
+ <Principals>
62
+ <Principal id="Author">
63
+ <LogonType>InteractiveToken</LogonType>
64
+ <RunLevel>LeastPrivilege</RunLevel>
65
+ </Principal>
66
+ </Principals>
67
+ <Settings>
68
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
69
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
70
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
71
+ <StartWhenAvailable>true</StartWhenAvailable>
72
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
73
+ <WakeToRun>false</WakeToRun>
74
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
75
+ <Priority>7</Priority>
76
+ </Settings>
77
+ <Actions Context="Author">
78
+ <Exec>
79
+ <Command>${xmlEscape(options.command)}</Command>
80
+ <Arguments>${xmlEscape(options.arguments)}</Arguments>
81
+ </Exec>
82
+ </Actions>
83
+ </Task>`;
84
+ }
85
+ export function buildLaunchdPlist(options) {
86
+ const programArgs = options.programArgs.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join('\n');
87
+ const env = Object.entries(options.env).map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`).join('\n');
88
+ return `<?xml version="1.0" encoding="UTF-8"?>
89
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
90
+ <plist version="1.0">
91
+ <dict>
92
+ <key>Label</key>
93
+ <string>${xmlEscape(options.label)}</string>
94
+ <key>ProgramArguments</key>
95
+ <array>
96
+ <string>${xmlEscape(options.program)}</string>
97
+ ${programArgs}
98
+ </array>
99
+ <key>WorkingDirectory</key>
100
+ <string>${xmlEscape(options.workDir)}</string>
101
+ <key>EnvironmentVariables</key>
102
+ <dict>
103
+ ${env}
104
+ </dict>
105
+ <key>RunAtLoad</key>
106
+ <true/>
107
+ <key>StandardOutPath</key>
108
+ <string>${xmlEscape(options.logPath)}</string>
109
+ <key>StandardErrorPath</key>
110
+ <string>${xmlEscape(options.logPath)}</string>
111
+ </dict>
112
+ </plist>`;
113
+ }
114
+ export function buildSystemdUnit(options) {
115
+ const execStart = options.execStart.map((part) => `"${part}"`).join(' ');
116
+ const env = Object.entries(options.env).map(([key, value]) => `Environment="${key}=${value}"`).join('\n');
117
+ return `[Unit]
118
+ Description=Khat proxy supervisor
119
+
120
+ [Service]
121
+ ExecStart=${execStart}
122
+ WorkingDirectory="${options.workDir}"
123
+ ${env}
124
+ Restart=no
125
+
126
+ [Install]
127
+ WantedBy=default.target
128
+ `;
129
+ }
130
+ // Windows: a scheduled task with an interactive token, not an NSSM service —
131
+ // the vault master key is protected with user-scope DPAPI (ADR 0004), which
132
+ // LocalSystem cannot decrypt, and NSSM cannot run as the current user without
133
+ // that user's password.
134
+ function windowsManager(options) {
135
+ const { runner, dataDir, distDir, execPath, userId } = resolveOptions(options);
136
+ const supervisorPath = join(distDir, 'supervisor.js');
137
+ async function install({ startNow = true } = {}) {
138
+ await mkdir(dataDir, { recursive: true });
139
+ const xmlPath = join(dataDir, 'service-task.xml');
140
+ // The task XML cannot set environment variables, so a cmd wrapper pins
141
+ // KHAT_HOME; the quoted `set` form avoids trailing-space capture.
142
+ const arguments_ = `/c set "KHAT_HOME=${dataDir}" && "${execPath}" "${supervisorPath}"`;
143
+ const xml = buildTaskXml({ userId, command: 'cmd.exe', arguments: arguments_ });
144
+ await writeFile(xmlPath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xml, 'utf16le')]));
145
+ const created = await runner('schtasks', ['/Create', '/F', '/TN', WINDOWS_TASK_NAME, '/XML', xmlPath]);
146
+ if (created.code !== 0)
147
+ throw new Error(`${commandFailure(created, 'could not register the scheduled task')} (definition kept at ${xmlPath})`);
148
+ await unlink(xmlPath).catch(() => undefined);
149
+ if (!startNow)
150
+ return `scheduled task '${WINDOWS_TASK_NAME}' registered; it auto-starts the daemon at your next logon`;
151
+ const started = await runner('schtasks', ['/Run', '/TN', WINDOWS_TASK_NAME]);
152
+ if (started.code !== 0)
153
+ throw new Error(commandFailure(started, 'task registered but could not be started'));
154
+ return `scheduled task '${WINDOWS_TASK_NAME}' registered and running; it auto-starts at logon`;
155
+ }
156
+ async function uninstall() {
157
+ const query = await runner('schtasks', ['/Query', '/TN', WINDOWS_TASK_NAME]);
158
+ if (query.code !== 0)
159
+ return 'khat service is not installed';
160
+ const removed = await runner('schtasks', ['/Delete', '/F', '/TN', WINDOWS_TASK_NAME]);
161
+ if (removed.code !== 0)
162
+ throw new Error(commandFailure(removed, 'could not remove the scheduled task'));
163
+ return `scheduled task '${WINDOWS_TASK_NAME}' removed`;
164
+ }
165
+ async function status() {
166
+ const query = await runner('schtasks', ['/Query', '/TN', WINDOWS_TASK_NAME]);
167
+ if (query.code !== 0)
168
+ return { installed: false, enabled: null, running: null, detail: '' };
169
+ // schtasks /Query status text is localized; PowerShell's StateEnum is not.
170
+ const state = await runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-ScheduledTask -TaskName '${WINDOWS_TASK_NAME}' -ErrorAction SilentlyContinue).State`]);
171
+ const value = Number.parseInt(state.stdout.trim(), 10);
172
+ if (!Number.isInteger(value))
173
+ return { installed: true, enabled: null, running: null, detail: 'task state could not be read' };
174
+ return {
175
+ installed: true,
176
+ enabled: value !== 1,
177
+ running: value === 4,
178
+ detail: value === 4 ? 'task running' : value === 3 ? 'task ready (not running)' : value === 1 ? 'task disabled' : `task state ${value}`
179
+ };
180
+ }
181
+ return { install, uninstall, status };
182
+ }
183
+ // macOS: launchd user agent. RunAtLoad starts it at login; KeepAlive stays off
184
+ // so the supervisor's own crash circuit breaker is the only restart authority.
185
+ function darwinManager(options) {
186
+ const { runner, homeDir, dataDir, distDir, execPath, uid } = resolveOptions(options);
187
+ const supervisorPath = join(distDir, 'supervisor.js');
188
+ const plistPath = join(homeDir, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
189
+ const target = `gui/${uid}/${LAUNCHD_LABEL}`;
190
+ async function install({ startNow = true } = {}) {
191
+ if (uid === undefined)
192
+ throw new Error('could not determine the user id for launchctl');
193
+ await mkdir(dirname(plistPath), { recursive: true });
194
+ await mkdir(dataDir, { recursive: true });
195
+ const plist = buildLaunchdPlist({
196
+ label: LAUNCHD_LABEL,
197
+ program: execPath,
198
+ programArgs: [supervisorPath],
199
+ workDir: distDir,
200
+ env: { KHAT_HOME: dataDir },
201
+ logPath: join(dataDir, 'service.log')
202
+ });
203
+ await writeFile(plistPath, plist, 'utf8');
204
+ if (!startNow)
205
+ return `launch agent ${LAUNCHD_LABEL} installed; it auto-starts the daemon at your next logon`;
206
+ await runner('launchctl', ['bootout', target]);
207
+ const boot = await runner('launchctl', ['bootstrap', `gui/${uid}`, plistPath]);
208
+ if (boot.code !== 0)
209
+ throw new Error(commandFailure(boot, 'could not load the launch agent'));
210
+ return `launch agent ${LAUNCHD_LABEL} installed and running; it auto-starts at logon`;
211
+ }
212
+ async function uninstall() {
213
+ if (!(await fileExists(plistPath)))
214
+ return 'khat service is not installed';
215
+ await runner('launchctl', ['bootout', target]);
216
+ await unlink(plistPath).catch(() => undefined);
217
+ return `launch agent ${LAUNCHD_LABEL} removed`;
218
+ }
219
+ async function status() {
220
+ if (!(await fileExists(plistPath)))
221
+ return { installed: false, enabled: null, running: null, detail: '' };
222
+ const printed = await runner('launchctl', ['print', target]);
223
+ if (printed.code !== 0)
224
+ return { installed: true, enabled: true, running: false, detail: 'not loaded; starts at next logon' };
225
+ const state = printed.stdout.match(/state\s*=\s*(\S+)/)?.[1] ?? 'unknown';
226
+ return { installed: true, enabled: true, running: state === 'running', detail: state === 'running' ? 'agent running' : `agent state: ${state}` };
227
+ }
228
+ return { install, uninstall, status };
229
+ }
230
+ // Linux: systemd user unit. Restart=no — the supervisor owns crash backoff;
231
+ // the unit only has to launch it once per login/boot.
232
+ function linuxManager(options) {
233
+ const { runner, homeDir, dataDir, distDir, execPath } = resolveOptions(options);
234
+ const supervisorPath = join(distDir, 'supervisor.js');
235
+ const unitPath = join(homeDir, '.config', 'systemd', 'user', `${SYSTEMD_UNIT}.service`);
236
+ async function install({ startNow = true } = {}) {
237
+ await mkdir(dirname(unitPath), { recursive: true });
238
+ const unit = buildSystemdUnit({ execStart: [execPath, supervisorPath], workDir: distDir, env: { KHAT_HOME: dataDir } });
239
+ await writeFile(unitPath, unit, 'utf8');
240
+ const reloaded = await runner('systemctl', ['--user', 'daemon-reload']);
241
+ if (reloaded.code !== 0)
242
+ throw new Error(commandFailure(reloaded, 'could not reload the systemd user units'));
243
+ const enable = await runner('systemctl', ['--user', ...(startNow ? ['enable', '--now'] : ['enable']), SYSTEMD_UNIT]);
244
+ if (enable.code !== 0)
245
+ throw new Error(commandFailure(enable, 'could not enable the service'));
246
+ // Without lingering, user units only run while the user is logged in.
247
+ const linger = await runner('loginctl', ['enable-linger']);
248
+ const lingerNote = linger.code === 0 ? '' : ' (warning: could not enable lingering, so the service starts at login rather than at boot)';
249
+ return `systemd user service ${SYSTEMD_UNIT} installed${startNow ? ' and running' : ''}; it auto-starts at login${lingerNote}`;
250
+ }
251
+ async function uninstall() {
252
+ if (!(await fileExists(unitPath)))
253
+ return 'khat service is not installed';
254
+ await runner('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]);
255
+ await unlink(unitPath).catch(() => undefined);
256
+ await runner('systemctl', ['--user', 'daemon-reload']);
257
+ return `systemd user service ${SYSTEMD_UNIT} removed`;
258
+ }
259
+ async function status() {
260
+ if (!(await fileExists(unitPath)))
261
+ return { installed: false, enabled: null, running: null, detail: '' };
262
+ const enabledResult = await runner('systemctl', ['--user', 'is-enabled', SYSTEMD_UNIT]);
263
+ const activeResult = await runner('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]);
264
+ const enabledText = enabledResult.stdout.trim();
265
+ const activeText = activeResult.stdout.trim();
266
+ return {
267
+ installed: true,
268
+ enabled: enabledText === 'enabled' ? true : enabledText === 'disabled' ? false : null,
269
+ running: activeText === 'active',
270
+ detail: activeText === 'active' ? 'unit active' : `unit ${activeText || 'unknown'}`
271
+ };
272
+ }
273
+ return { install, uninstall, status };
274
+ }
275
+ export function createServiceManager(platform, options = {}) {
276
+ if (platform === 'win32')
277
+ return windowsManager(options);
278
+ if (platform === 'darwin')
279
+ return darwinManager(options);
280
+ if (platform === 'linux')
281
+ return linuxManager(options);
282
+ throw new Error(`unsupported platform '${platform}': khat service requires Windows Task Scheduler, macOS launchd, or Linux systemd`);
283
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alilis/k-hat",
3
- "version": "0.2.7",
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,8 +36,8 @@
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",
39
+ "start": "node dist/cli/cli.js start",
40
+ "khat": "node dist/cli/cli.js",
41
41
  "test": "npm run build && node --test test/**/*.test.js",
42
42
  "bench": "node bench/bench.js"
43
43
  },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes