@alilis/k-hat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/admin.js +213 -0
- package/dist/cli.js +700 -0
- package/dist/config.js +84 -0
- package/dist/doctor.js +123 -0
- package/dist/dpapi.js +34 -0
- package/dist/key-protector.js +94 -0
- package/dist/logger.js +67 -0
- package/dist/portable-vault.js +146 -0
- package/dist/router.js +10 -0
- package/dist/selector.js +23 -0
- package/dist/server.js +228 -0
- package/dist/store.js +240 -0
- package/dist/supervisor.js +98 -0
- package/dist/tui-client.js +87 -0
- package/dist/tui-main.js +8 -0
- package/dist/tui-types.js +1 -0
- package/dist/tui.js +286 -0
- package/dist/types.js +1 -0
- package/dist/vault.js +96 -0
- package/dist/web-ui.js +323 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aweirdocc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# K-Hat(khat)
|
|
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 钩子)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
执行 `npm link` 注册全局命令,效果与方式一相同;或者不改动全局环境,停留在仓库目录内,把下文所有的 `khat <命令>` 换成 `npm run khat -- <命令>`:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm run khat -- status
|
|
44
|
+
npm run khat -- tui
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 快速开始
|
|
48
|
+
|
|
49
|
+
```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
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
然后在你的工具中把 `base_url` 指向 `http://127.0.0.1:8787/v1`(或 `http://127.0.0.1:8787`,视工具而定),认证令牌填 `khat init` 生成的访问令牌。
|
|
80
|
+
|
|
81
|
+
## CLI 命令
|
|
82
|
+
|
|
83
|
+
### 初始化与运行
|
|
84
|
+
|
|
85
|
+
> 源码运行时无需手动编译:`npm install` 会通过 prepare 钩子自动构建;若手动改过源码,重新执行 `npm run build` 即可。安装为全局命令(`npm link`)后可直接使用 `khat ...`,否则在仓库内用 `npm run khat -- ...`。
|
|
86
|
+
|
|
87
|
+
| 命令 | 说明 |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `khat init` | 首次初始化:创建密钥库 + 访问令牌 + 默认配置 |
|
|
90
|
+
| `khat start` | 后台启动代理与 supervisor;使用 `--foreground` 以前台模式诊断 |
|
|
91
|
+
| `khat stop` | 请求后台代理优雅停止 |
|
|
92
|
+
| `khat status` | 查看 Provider、key、路由及 key 健康状态 |
|
|
93
|
+
| `khat doctor` | 检测本机 Agent Tool 配置并输出接入提示 |
|
|
94
|
+
| `khat log [--tail <n>]` | 查看最近的脱敏请求日志 |
|
|
95
|
+
| `khat ui` | 签发一次性票据并打开本地管理页 |
|
|
96
|
+
| `khat tui` | 在交互式终端中查看 daemon 状态、Provider/Key、路由和请求日志 |
|
|
97
|
+
|
|
98
|
+
> Provider、key 和路由的新增、更新、删除与恢复等管理变更通过本地 Admin API 执行;若 daemon 尚未运行,命令会自动启动它。
|
|
99
|
+
|
|
100
|
+
### 访问令牌
|
|
101
|
+
|
|
102
|
+
| 命令 | 说明 |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `khat token show` | 打印当前访问令牌 |
|
|
105
|
+
| `khat token rotate` | 轮换访问令牌(需更新所有工具) |
|
|
106
|
+
|
|
107
|
+
### Provider 管理
|
|
108
|
+
|
|
109
|
+
| 命令 | 说明 |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `khat provider add <id> --base-url <url> [--name <n>] [--protocol openai\|anthropic]` | 添加 Provider |
|
|
112
|
+
| `khat provider update <id> [--name <n>] [--base-url <url>] [--protocol openai\|anthropic]` | 更新 Provider 名称、上游地址或协议 |
|
|
113
|
+
| `khat provider list` | 列出所有 Provider |
|
|
114
|
+
| `khat provider remove <id>` | 删除 Provider(级联删除其 key 与路由) |
|
|
115
|
+
|
|
116
|
+
### Key 管理(机密存于加密密钥库)
|
|
117
|
+
|
|
118
|
+
`--weight <n>`:该 key 在同一 Provider 内多个 key 之间的相对权重(平滑加权轮询,Nginx 同款算法),默认 1。例如 `--weight 3` 与 `--weight 1` 的两个 key,在都可用时请求约按 3:1 分配;权重只影响轮询占比,不影响 key 的可用性。
|
|
119
|
+
|
|
120
|
+
| 命令 | 说明 |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `khat key add <provider> <id> [--weight <n>] [--value <v>]` | 添加 key(省略 `--value` 时交互式提示) |
|
|
123
|
+
| `khat key update <provider> <id> [--weight <n>] [--value <v>]` | 更新 key 权重或机密值 |
|
|
124
|
+
| `khat key list [<provider>]` | 列出 key(默认脱敏,仅显示尾 4 位) |
|
|
125
|
+
| `khat key remove <provider> <id>` | 删除 key |
|
|
126
|
+
| `khat key enable <provider> <id>` | 将被标记不可用的 key 恢复为可用 |
|
|
127
|
+
| `khat key test <provider> <id> [--model <m>]` | 直连配置的上游发送一次最小请求探活;成功后恢复 key,不会仅因探测返回 401/402/429 就标记 key 不可用 |
|
|
128
|
+
|
|
129
|
+
### 路由管理
|
|
130
|
+
|
|
131
|
+
| 命令 | 说明 |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `khat route add <model> <provider>` | 添加「模型名 -> Provider」路由 |
|
|
134
|
+
| `khat route update <model> <provider>` | 更新模型路由指向的 Provider |
|
|
135
|
+
| `khat route list` | 列出所有路由 |
|
|
136
|
+
| `khat route remove <model>` | 删除路由 |
|
|
137
|
+
|
|
138
|
+
### 环境变量
|
|
139
|
+
|
|
140
|
+
| 变量 | 说明 |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `KHAT_HOME` | 数据目录(默认 `~/.khat`) |
|
|
143
|
+
| `KHAT_ACCESS_TOKEN` | 覆盖密钥库中的访问令牌(主要供测试) |
|
|
144
|
+
|
|
145
|
+
## 常见异常与处理
|
|
146
|
+
|
|
147
|
+
### Key 不可用与自动切换
|
|
148
|
+
|
|
149
|
+
代理转发路径上,上游返回 **401**(鉴权失败)、**402**(余额耗尽)或 **429**(限流)时,当前 key 会被标记为不可用并跳过,请求自动切换到同一 Provider 的其他可用 key 重发(仅在响应首字节返回前)。若该 Provider 下所有 key 都不可用,请求返回错误。
|
|
150
|
+
|
|
151
|
+
- **查看**:`khat status` / `khat key list` 中该 key 显示为 `unavailable`,并附最近一次错误(HTTP 状态与时间)。
|
|
152
|
+
- **恢复**:`khat key enable <provider> <id>` 手动恢复;或 `khat key test <provider> <id>` 探活,成功后自动恢复。
|
|
153
|
+
- **注意**:`key test` 直接连接上游探测,**不会**因返回 401/402/429 就把 key 标记为不可用——不可用标记只发生在代理转发路径上。
|
|
154
|
+
|
|
155
|
+
### CLI 常见报错
|
|
156
|
+
|
|
157
|
+
| 报错信息 | 原因 | 处理 |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `not initialized; run 'khat init' first` | 数据目录尚无密钥库 | 先执行 `khat init` |
|
|
160
|
+
| `no access token found; run khat init` | 密钥库无访问令牌 | `khat init`,或用 `KHAT_ACCESS_TOKEN` 提供 |
|
|
161
|
+
| `port <n> is already occupied by process <pid>` | 端口被其它进程占用 | `khat stop`,或用 `taskkill /PID <pid> /T /F` 结束占用进程 |
|
|
162
|
+
| `daemon started but health check failed` | 后台 daemon 启动异常 | `khat start --foreground` 前台运行观察日志 |
|
|
163
|
+
| `--weight must be a positive integer` | 权重不是正整数 | 传入 ≥1 的整数 |
|
|
164
|
+
| `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/)。
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { addProvider, updateProvider, removeProvider, addKey, updateKey, removeKey, enableKey, addRoute, updateRoute, removeRoute, maskSecret } from './store.js';
|
|
2
|
+
import { LogWriter } from './logger.js';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { UiSessions, serveUi } from './web-ui.js';
|
|
5
|
+
import { ACCESS_TOKEN_REF, generateAccessToken } from './vault.js';
|
|
6
|
+
// Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
|
|
7
|
+
// only from loopback and behind the proxy access token, so a local non-loopback
|
|
8
|
+
// client or a process without the token cannot mutate config/state/vault.
|
|
9
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
10
|
+
const uiSessions = new UiSessions();
|
|
11
|
+
function json(res, status, value) {
|
|
12
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
13
|
+
res.end(JSON.stringify(value));
|
|
14
|
+
}
|
|
15
|
+
async function readJsonBody(req) {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
for await (const chunk of req)
|
|
18
|
+
chunks.push(Buffer.from(chunk));
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error('Invalid JSON body');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function buildStatus(store) {
|
|
27
|
+
const token = store.vault.get(ACCESS_TOKEN_REF);
|
|
28
|
+
return {
|
|
29
|
+
listen: `http://${store.config.bind}:${store.config.port}`,
|
|
30
|
+
accessToken: token ? maskSecret(token) : null,
|
|
31
|
+
providers: store.config.providers.map((provider) => ({
|
|
32
|
+
id: provider.id,
|
|
33
|
+
name: provider.name,
|
|
34
|
+
protocol: provider.protocol,
|
|
35
|
+
baseUrl: provider.baseUrl,
|
|
36
|
+
keys: provider.keys.map((key) => {
|
|
37
|
+
const state = store.states[`${provider.id}/${key.id}`];
|
|
38
|
+
const secret = store.vault.get(key.vaultRef);
|
|
39
|
+
return {
|
|
40
|
+
id: key.id,
|
|
41
|
+
weight: key.weight,
|
|
42
|
+
status: state?.status === 'unavailable' ? 'unavailable' : 'available',
|
|
43
|
+
lastError: state?.lastError,
|
|
44
|
+
secret: secret ? maskSecret(secret) : null,
|
|
45
|
+
counters: store.counters[`${provider.id}/${key.id}`] ?? { requests: 0, failed: 0, bytesOut: 0, tokensIn: 0, tokensOut: 0 }
|
|
46
|
+
};
|
|
47
|
+
})
|
|
48
|
+
})),
|
|
49
|
+
routes: store.config.routes
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function route(req, res, store, sessionAuthorized = false) {
|
|
53
|
+
const url = new URL(req.url, 'http://localhost');
|
|
54
|
+
const seg = url.pathname.split('/').filter(Boolean); // e.g. ['_keys', 'providers', 'p', 'keys']
|
|
55
|
+
const sub = seg.slice(1); // drop the leading '_keys'
|
|
56
|
+
const method = req.method ?? '';
|
|
57
|
+
if (sub[0] === 'ticket' && sub.length === 1 && method === 'POST') {
|
|
58
|
+
if (sessionAuthorized)
|
|
59
|
+
throw new Error('only the access token may create tickets');
|
|
60
|
+
return json(res, 200, { ticket: uiSessions.createTicket(), expiresInMs: 60_000 });
|
|
61
|
+
}
|
|
62
|
+
if (sub[0] === 'ticket' && sub[1] === 'session' && method === 'POST') {
|
|
63
|
+
const body = await readJsonBody(req);
|
|
64
|
+
if (typeof body.ticket !== 'string')
|
|
65
|
+
throw new Error('ticket is required');
|
|
66
|
+
const session = uiSessions.exchange(body.ticket);
|
|
67
|
+
if (!session)
|
|
68
|
+
return json(res, 401, { error: { message: 'Ticket is invalid or expired' } });
|
|
69
|
+
return json(res, 200, { session });
|
|
70
|
+
}
|
|
71
|
+
if (sub[0] === 'ui' && method === 'GET') {
|
|
72
|
+
if (!serveUi(req, res))
|
|
73
|
+
return json(res, 404, { error: { message: 'Not found' } });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (sub[0] === 'health' && method === 'GET')
|
|
77
|
+
return json(res, 200, { ok: true });
|
|
78
|
+
if (sub[0] === 'token' && sub[1] === 'rotate' && method === 'POST') {
|
|
79
|
+
const token = generateAccessToken();
|
|
80
|
+
await store.mutate(async () => { store.vault.set(ACCESS_TOKEN_REF, token); await store.vault.save(); });
|
|
81
|
+
return json(res, 200, { ok: true, token });
|
|
82
|
+
}
|
|
83
|
+
if (sub[0] === 'status' && method === 'GET')
|
|
84
|
+
return json(res, 200, buildStatus(store));
|
|
85
|
+
if (sub[0] === 'logs' && method === 'GET') {
|
|
86
|
+
const tail = Number.parseInt(url.searchParams.get('tail') ?? '50', 10);
|
|
87
|
+
const logs = await new LogWriter(join(store.dir, 'logs')).recent(Number.isFinite(tail) ? tail : 50);
|
|
88
|
+
const model = url.searchParams.get('model');
|
|
89
|
+
const key = url.searchParams.get('key');
|
|
90
|
+
const status = url.searchParams.get('status');
|
|
91
|
+
return json(res, 200, { logs: logs.filter((entry) => (!model || entry.model === model) && (!key || entry.key === key) && (!status || String(entry.status) === status)) });
|
|
92
|
+
}
|
|
93
|
+
if (sub[0] === 'providers') {
|
|
94
|
+
if (sub.length === 1 && method === 'POST') {
|
|
95
|
+
const body = await readJsonBody(req);
|
|
96
|
+
if (!body.id || !body.protocol || !body.baseUrl)
|
|
97
|
+
throw new Error('id, protocol and baseUrl are required');
|
|
98
|
+
await store.mutate(() => addProvider(store, { id: body.id, name: body.name, protocol: body.protocol, baseUrl: body.baseUrl }));
|
|
99
|
+
return json(res, 200, { ok: true });
|
|
100
|
+
}
|
|
101
|
+
if (sub.length === 2 && method === 'PUT') {
|
|
102
|
+
const body = await readJsonBody(req);
|
|
103
|
+
if (body.name !== undefined && typeof body.name !== 'string')
|
|
104
|
+
throw new Error('name must be a string');
|
|
105
|
+
if (body.protocol !== undefined && body.protocol !== 'openai' && body.protocol !== 'anthropic')
|
|
106
|
+
throw new Error('protocol must be openai or anthropic');
|
|
107
|
+
if (body.baseUrl !== undefined && typeof body.baseUrl !== 'string')
|
|
108
|
+
throw new Error('baseUrl must be a string');
|
|
109
|
+
await store.mutate(() => updateProvider(store, sub[1], body));
|
|
110
|
+
return json(res, 200, { ok: true });
|
|
111
|
+
}
|
|
112
|
+
if (sub.length === 2 && method === 'DELETE') {
|
|
113
|
+
await store.mutate(() => removeProvider(store, sub[1]));
|
|
114
|
+
return json(res, 200, { ok: true });
|
|
115
|
+
}
|
|
116
|
+
if (sub.length === 3 && sub[2] === 'keys' && method === 'POST') {
|
|
117
|
+
const body = await readJsonBody(req);
|
|
118
|
+
if (!body.id || typeof body.value !== 'string')
|
|
119
|
+
throw new Error('id and value are required');
|
|
120
|
+
if (body.weight !== undefined && (!Number.isInteger(body.weight) || body.weight < 1))
|
|
121
|
+
throw new Error('weight must be a positive integer');
|
|
122
|
+
await store.mutate(() => addKey(store, sub[1], body.id, body.value, body.weight ?? 1));
|
|
123
|
+
return json(res, 200, { ok: true });
|
|
124
|
+
}
|
|
125
|
+
if (sub.length === 4 && sub[2] === 'keys' && method === 'PUT') {
|
|
126
|
+
const body = await readJsonBody(req);
|
|
127
|
+
if (body.weight !== undefined && (!Number.isInteger(body.weight) || body.weight < 1))
|
|
128
|
+
throw new Error('weight must be a positive integer');
|
|
129
|
+
if (body.value !== undefined && (typeof body.value !== 'string' || !body.value))
|
|
130
|
+
throw new Error('value must be a non-empty string');
|
|
131
|
+
await store.mutate(() => updateKey(store, sub[1], sub[3], body));
|
|
132
|
+
return json(res, 200, { ok: true });
|
|
133
|
+
}
|
|
134
|
+
if (sub.length === 4 && sub[2] === 'keys' && method === 'DELETE') {
|
|
135
|
+
await store.mutate(() => removeKey(store, sub[1], sub[3]));
|
|
136
|
+
return json(res, 200, { ok: true });
|
|
137
|
+
}
|
|
138
|
+
if (sub.length === 5 && sub[2] === 'keys' && sub[4] === 'enable' && method === 'POST') {
|
|
139
|
+
await store.mutate(() => enableKey(store, sub[1], sub[3]));
|
|
140
|
+
return json(res, 200, { ok: true });
|
|
141
|
+
}
|
|
142
|
+
if (sub.length === 5 && sub[2] === 'keys' && sub[4] === 'probe' && method === 'POST') {
|
|
143
|
+
const provider = store.config.providers.find((item) => item.id === sub[1]);
|
|
144
|
+
const key = provider?.keys.find((item) => item.id === sub[3]);
|
|
145
|
+
if (!provider || !key)
|
|
146
|
+
throw new Error('unknown provider or key');
|
|
147
|
+
const secret = store.vault.get(key.vaultRef);
|
|
148
|
+
const model = store.config.routes.find((item) => item.provider === provider.id)?.model;
|
|
149
|
+
if (!secret || !model)
|
|
150
|
+
throw new Error('a key and route model are required for probing');
|
|
151
|
+
const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
|
|
152
|
+
const headers = provider.protocol === 'anthropic' ? { 'content-type': 'application/json', 'x-api-key': secret, 'anthropic-version': '2023-06-01' } : { 'content-type': 'application/json', authorization: `Bearer ${secret}` };
|
|
153
|
+
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 };
|
|
154
|
+
const response = await fetch(new URL(path, provider.baseUrl), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
|
|
155
|
+
if (response.ok)
|
|
156
|
+
await store.mutate(() => enableKey(store, provider.id, key.id));
|
|
157
|
+
return json(res, response.ok ? 200 : 502, { ok: response.ok, status: response.status });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (sub[0] === 'routes') {
|
|
161
|
+
if (sub.length === 1 && method === 'POST') {
|
|
162
|
+
const body = await readJsonBody(req);
|
|
163
|
+
if (!body.model || !body.provider)
|
|
164
|
+
throw new Error('model and provider are required');
|
|
165
|
+
await store.mutate(() => addRoute(store, body.model, body.provider));
|
|
166
|
+
return json(res, 200, { ok: true });
|
|
167
|
+
}
|
|
168
|
+
if (sub.length === 2 && method === 'PUT') {
|
|
169
|
+
const body = await readJsonBody(req);
|
|
170
|
+
if (!body.provider || typeof body.provider !== 'string')
|
|
171
|
+
throw new Error('provider is required');
|
|
172
|
+
await store.mutate(() => updateRoute(store, decodeURIComponent(sub[1]), body.provider));
|
|
173
|
+
return json(res, 200, { ok: true });
|
|
174
|
+
}
|
|
175
|
+
if (sub.length === 1 && method === 'DELETE') {
|
|
176
|
+
// The model goes in the query string: model names are not restricted to
|
|
177
|
+
// the provider/key id alphabet and may contain characters unsafe in a path segment.
|
|
178
|
+
const model = url.searchParams.get('model');
|
|
179
|
+
if (!model)
|
|
180
|
+
throw new Error('model query parameter is required');
|
|
181
|
+
await store.mutate(() => removeRoute(store, model));
|
|
182
|
+
return json(res, 200, { ok: true });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return json(res, 404, { error: { message: 'Not found' } });
|
|
186
|
+
}
|
|
187
|
+
export async function handleAdmin(req, res, store, accessToken) {
|
|
188
|
+
const remote = req.socket.remoteAddress ?? '';
|
|
189
|
+
if (!LOOPBACK.has(remote))
|
|
190
|
+
return json(res, 403, { error: { message: 'Admin API is reachable only from loopback' } });
|
|
191
|
+
const url = new URL(req.url, 'http://localhost');
|
|
192
|
+
const host = req.headers.host;
|
|
193
|
+
if (host) {
|
|
194
|
+
const hostname = host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0];
|
|
195
|
+
const allowed = hostname === store.config.bind || (store.config.bind === '127.0.0.1' && hostname === 'localhost');
|
|
196
|
+
if (!allowed)
|
|
197
|
+
return json(res, 403, { error: { message: 'Invalid Host header' } });
|
|
198
|
+
}
|
|
199
|
+
const isUiBootstrap = req.method === 'GET' && (url.pathname === '/_keys/ui' || url.pathname === '/_keys/ui/');
|
|
200
|
+
const isTicketExchange = req.method === 'POST' && url.pathname === '/_keys/ticket/session';
|
|
201
|
+
const bearer = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : undefined;
|
|
202
|
+
const sessionAuthorized = uiSessions.valid(bearer);
|
|
203
|
+
const currentAccessToken = typeof accessToken === 'function' ? accessToken() : accessToken;
|
|
204
|
+
const accessAuthorized = currentAccessToken !== undefined && bearer === currentAccessToken;
|
|
205
|
+
if (!isUiBootstrap && !isTicketExchange && !accessAuthorized && !sessionAuthorized)
|
|
206
|
+
return json(res, 401, { error: { message: 'Unauthorized' } });
|
|
207
|
+
try {
|
|
208
|
+
await route(req, res, store, sessionAuthorized);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
json(res, 400, { error: { message: error?.message ?? 'Bad request' } });
|
|
212
|
+
}
|
|
213
|
+
}
|