mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6733534282 | |||
| 499f0cac2f | |||
| 7fb8f96b82 | |||
| 6a34cd6f86 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -26,7 +26,3 @@ cmd/gui/dist/
|
||||
|
||||
# local design/working notes (not part of the shipped repo)
|
||||
/plan.md
|
||||
|
||||
# scratch build/probe dirs used while debugging (GOTMPDIR, throwaway binaries)
|
||||
.build-work/
|
||||
.probe/
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "pong",
|
||||
"role": "assistant"
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion",
|
||||
"usage": {
|
||||
"completion": 1,
|
||||
"completion_tokens": 1,
|
||||
"prompt": 3,
|
||||
"prompt_tokens": 3,
|
||||
"total": 4,
|
||||
"total_tokens": 4
|
||||
}
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "Hel",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "lo",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": null,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": "",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"content": ""
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk"
|
||||
}
|
||||
{
|
||||
"choices": [],
|
||||
"created": <TS>,
|
||||
"id": "<ID>",
|
||||
"model": "good-m",
|
||||
"object": "chat.completion.chunk",
|
||||
"usage": {
|
||||
"completion": 1,
|
||||
"completion_tokens": 1,
|
||||
"prompt": 16,
|
||||
"prompt_tokens": 16,
|
||||
"total": 17,
|
||||
"total_tokens": 17
|
||||
}
|
||||
}
|
||||
[DONE]
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"created": <TS>,
|
||||
"data": [
|
||||
{
|
||||
"b64_json": "QUJD"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,501 +0,0 @@
|
||||
# ModelRouter / llmsproxy 交接文档
|
||||
|
||||
> **历史文档**:本文记录的渠道能力(channels / quota 插件与 4 个私有协议适配器)
|
||||
> 已于 v3.0.2 按合规要求整体移出仓库。保留原文仅作历史记录,其中的路径、
|
||||
> 模块名与适配器清单不再代表当前代码。
|
||||
|
||||
更新时间:2026-10-19
|
||||
工作目录:`/home/program/llmsproxy`
|
||||
当前分支:`release/v2.0.x`
|
||||
|
||||
## 1. 当前 Git 状态
|
||||
|
||||
最近提交:
|
||||
|
||||
- `c9f3453 fix(codearts): 识别 InferHub 未注册模型`
|
||||
- `12bfd53 fix: 收口渠道源迁移与测试页合并`
|
||||
- `25970a2 refactor(scheduler)!: 移除 priority 废弃字段 + 一通道一源 + discover 复用凭据`
|
||||
|
||||
`c9f3453` 已推送到 `origin/release/v2.0.x`。
|
||||
|
||||
注意:当前工作树在最后一轮添加 provider 回归测试时出现了一个**未完成且可能无法编译的测试编辑**。最后一次工具报错:
|
||||
|
||||
```text
|
||||
internal/provider/provider_test.go:99: missing ',' in argument list
|
||||
```
|
||||
|
||||
下一位 agent 接手后的第一步必须是:
|
||||
|
||||
```bash
|
||||
cd /home/program/llmsproxy
|
||||
git status --short
|
||||
gofmt -w internal/provider/provider_test.go 2>&1 || true
|
||||
go test -tags luajit ./internal/provider/ 2>&1
|
||||
```
|
||||
|
||||
如果测试文件处于半编辑状态,先检查 `TestProviderChatStreamNormalizesStopAfterToolCall`,修正 Go 字符串转义或删除该未完成测试后再继续。不要直接部署当前工作树。
|
||||
|
||||
## 2. 生产服务状态
|
||||
|
||||
生产服务:`llmsproxy.service`
|
||||
|
||||
- 当前服务 active
|
||||
- 监听:`0.0.0.0:8081`
|
||||
- 二进制已通过 `./deploy.sh` 原子替换并重启
|
||||
- 最近一次部署健康检查通过
|
||||
- 当前生产插件 SHA256 前缀曾核对为:`1bf26cbb3abcb107`
|
||||
- 当前生产二进制在最近部署后已更新
|
||||
|
||||
不要把密钥写入交接记录。生产 admin key 保存在本机运维环境中,不应复制到聊天、文档或提交。
|
||||
|
||||
## 3. Channel 源当前状态
|
||||
|
||||
生产源已经从旧的“一账号一源”收口成“一渠道一源”:
|
||||
|
||||
```text
|
||||
channels-trae
|
||||
channels-minimax
|
||||
channels-lobsterai
|
||||
channels-buddy
|
||||
channels-codearts
|
||||
channels-zcode
|
||||
```
|
||||
|
||||
旧形态源已经清理:
|
||||
|
||||
```text
|
||||
channels-<provider>-<account_id>
|
||||
```
|
||||
|
||||
此前旧源和新源并存,造成同一渠道同一模型出现多份,只能靠账号 hex 区分。
|
||||
|
||||
根因已修复:内核 `list_sources` 返回裸数组,但旧插件代码按 `{sources = [...]}` 信封读取,导致迁移清理循环永远为空。当前 `channels.lua` 已兼容:
|
||||
|
||||
- 裸数组
|
||||
- `{sources = [...]}`
|
||||
- `{json = {sources = [...]}}`
|
||||
|
||||
相关代码:
|
||||
|
||||
```text
|
||||
internal/lua/plugins/channels.lua: syncProviderSource
|
||||
internal/core/channels_legacy_source_cleanup_test.go
|
||||
```
|
||||
|
||||
当前生产源列表已实测只有 6 个新形态源。
|
||||
|
||||
## 4. CodeArts 当前状态
|
||||
|
||||
### 已确认可用的部分
|
||||
|
||||
CodeArts 使用:
|
||||
|
||||
```text
|
||||
adapter = codearts
|
||||
base_url = https://snap-access.cn-north-4.myhuaweicloud.com
|
||||
```
|
||||
|
||||
CodeArts 的 chat 请求需要完整 AK/SK/security token 凭据,由插件适配器签名,不是裸 Bearer token。
|
||||
|
||||
真实流式工具调用测试已经成功:
|
||||
|
||||
请求形状:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "channels-codearts:openpangu-2.0-flash",
|
||||
"messages": [{"role": "user", "content": "Use the weather tool for Beijing."}],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
}],
|
||||
"tool_choice": "auto",
|
||||
"stream": true,
|
||||
"max_tokens": 64
|
||||
}
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- HTTP 200
|
||||
- `Content-Type: text/event-stream`
|
||||
- 返回结构化 `tool_calls`
|
||||
- 函数名 `get_weather`
|
||||
- 参数最终为 `{"city":"Beijing"}`
|
||||
- `finish_reason = tool_calls`
|
||||
|
||||
因此 CodeArts 的流式工具调用链路当前是通的。
|
||||
|
||||
### CodeArts 模型刷新
|
||||
|
||||
生产调用:
|
||||
|
||||
```http
|
||||
POST /api/plugins/http/channels/model/refresh
|
||||
{"platform":"codearts"}
|
||||
```
|
||||
|
||||
实测返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"results": [
|
||||
{"platform": "codearts", "ok": true, "count": 11}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
CodeArts 模型目录来自两个签名端点并合并:
|
||||
|
||||
1. `opengw.developer.huaweicloud.com/api/v1/gateway/config`
|
||||
- `result.models`
|
||||
2. `snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin`
|
||||
- `builtinModels`
|
||||
|
||||
相关解析:
|
||||
|
||||
```text
|
||||
internal/lua/plugins/channels.lua: parseRemoteModels
|
||||
internal/lua/plugins/channels.lua: refreshRemoteModels
|
||||
internal/core/channels_codearts_catalog_test.go
|
||||
```
|
||||
|
||||
### 已确认失效并禁用的 CodeArts 模型
|
||||
|
||||
下面 4 个模型曾收到明确的上游 404:
|
||||
|
||||
```text
|
||||
CodeArts-Pangu
|
||||
deepseek-v4-flash-0731
|
||||
deepseek-v4-pro-0813
|
||||
glm-5.3-flash
|
||||
```
|
||||
|
||||
生产上已通过 `channels/model/set_disabled` 禁用。它们仍可能在 `model/list` 中出现,但 `disabled=true`,不会作为可用模型路由。
|
||||
|
||||
新增的死模型判据:
|
||||
|
||||
```lua
|
||||
model is not registered
|
||||
```
|
||||
|
||||
对应提交:`c9f3453`。
|
||||
|
||||
注意:当前插件的 dead-model 机制只在收到真实请求失败后记录模型;刷新目录不会自动根据“远端目录没有该模型”删除模型,这是刻意设计,避免误删远端目录不完整的平台模型。
|
||||
|
||||
CodeArts 当前仍有一些模型返回 429/TPM 限流,例如:
|
||||
|
||||
```text
|
||||
deepseek-v4-flash
|
||||
deepseek-v4.1-flash
|
||||
```
|
||||
|
||||
429 不是模型失效,不能禁用。
|
||||
|
||||
## 5. 其他 Channel 实测结果
|
||||
|
||||
### Trae
|
||||
|
||||
真实流式 + tools 请求:
|
||||
|
||||
```text
|
||||
channels-trae:DeepSeek-V4-Flash
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- HTTP 200
|
||||
- SSE 正常返回
|
||||
- 结构化 tool call 返回
|
||||
- 但上游最后的结束原因可能是 `stop`,即使前面已经出现 tool-call fragments
|
||||
|
||||
这会让 Agent 把工具调用误判成普通文本完成。
|
||||
|
||||
当前准备中的修复在统一 provider 流式层:
|
||||
|
||||
```text
|
||||
internal/provider/provider.go
|
||||
```
|
||||
|
||||
逻辑是:只要本次流出现真实 tool-call 片段,后续 `FinishReason == "stop"` 就归一成 `tool_calls`。
|
||||
|
||||
另外,Trae 的非流式接口偶尔返回 SSE。当前 `trae.lua` 对非流式 SSE 只取最后一条 data,不能完整合并多条工具调用/推理片段。这是后续仍需处理的协议问题,但不能用非流式手工请求结果推断 Pi 的真实请求模式。
|
||||
|
||||
### Buddy
|
||||
|
||||
真实流式 + tools 请求:
|
||||
|
||||
```text
|
||||
channels-buddy:glm-5.3-flash
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- HTTP 200
|
||||
- SSE 正常返回
|
||||
- tool-call 参数分片可见
|
||||
- 最终有 `finish_reason = tool_calls`
|
||||
- 但某些上游 chunk 的 `tool_calls` 可能是空对象 `{}`
|
||||
|
||||
空对象不是合法的 OpenAI tool-call 数组,部分 Agent 客户端会拒绝。
|
||||
|
||||
当前准备中的修复在:
|
||||
|
||||
```text
|
||||
internal/lua/adapters/buddy.lua
|
||||
```
|
||||
|
||||
只在 `type(delta.tool_calls) == "table" and #delta.tool_calls > 0` 时输出 `tool_calls`,避免 Lua 空表被编码为 `{}`。
|
||||
|
||||
Buddy 的非流式错误:
|
||||
|
||||
```text
|
||||
11101: Non-stream chat request is currently not supported
|
||||
```
|
||||
|
||||
之前出现的该错误来自手工 `curl` 测试中显式设置 `stream:false`,不能归因到 Pi。真实 `stream:true + tools` 已经证明 Buddy 流式工具调用可以工作。
|
||||
|
||||
### MiniMax
|
||||
|
||||
生产日志反复出现:
|
||||
|
||||
```text
|
||||
adapter "anthropic": status=401 body={"error":"invalid access token"}
|
||||
provider channels-minimax: api error 401
|
||||
```
|
||||
|
||||
这是凭据失效问题,不是 tools 形状问题。
|
||||
|
||||
当前源:
|
||||
|
||||
```text
|
||||
channels-minimax
|
||||
adapter = anthropic
|
||||
endpoint = /mavis/api/v1/llm/v1/messages
|
||||
```
|
||||
|
||||
需要用户重新登录/提供有效凭据。不要擅自刷新一次性 refresh token,之前已经发生过 refresh token 被消耗后无法再次使用的事故。
|
||||
|
||||
## 6. 当前未提交的代码改动
|
||||
|
||||
最后一轮已经改动但尚未完成提交:
|
||||
|
||||
```text
|
||||
internal/provider/provider.go
|
||||
internal/lua/adapters/trae.lua
|
||||
internal/lua/adapters/buddy.lua
|
||||
internal/provider/provider_test.go # 可能有半编辑、需先修复
|
||||
```
|
||||
|
||||
已完成的意图:
|
||||
|
||||
1. provider 流式层记录 `sawToolCalls`
|
||||
2. 看到真实 tool-call 后,把后续 `stop` 归一为 `tool_calls`
|
||||
3. Trae 不输出空工具调用表
|
||||
4. Buddy 不输出空工具调用表
|
||||
5. provider 回归测试模拟“tool fragment + stop”场景
|
||||
|
||||
但 provider 测试最后一次编辑工具报错:
|
||||
|
||||
```text
|
||||
internal/provider/provider_test.go:99: missing ',' in argument list
|
||||
```
|
||||
|
||||
因此必须先修测试文件,再提交。
|
||||
|
||||
## 7. 推荐接手顺序
|
||||
|
||||
### 第一步:修复工作树
|
||||
|
||||
```bash
|
||||
cd /home/program/llmsproxy
|
||||
git status --short
|
||||
sed -n '1,150p' internal/provider/provider_test.go
|
||||
gofmt -w internal/provider/provider_test.go 2>&1 || true
|
||||
go test -tags luajit ./internal/provider/ 2>&1
|
||||
```
|
||||
|
||||
若测试函数字符串转义难以修复,先删除半成品测试,再重新用 raw string 写测试。
|
||||
|
||||
### 第二步:跑定向验证
|
||||
|
||||
```bash
|
||||
luac -p internal/lua/adapters/trae.lua
|
||||
luac -p internal/lua/adapters/buddy.lua
|
||||
go test -tags luajit ./internal/provider/
|
||||
go test -tags luajit ./internal/lua/ -run 'Adapter|Buddy|Trae|Stream'
|
||||
```
|
||||
|
||||
### 第三步:构建并部署临时验证版
|
||||
|
||||
修复确认后:
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
部署前确认:
|
||||
|
||||
```bash
|
||||
sha256sum internal/lua/plugins/channels.lua
|
||||
sha256sum /etc/llmsproxy/plugins/channels.lua
|
||||
systemctl is-active llmsproxy
|
||||
```
|
||||
|
||||
### 第四步:真实流式回归
|
||||
|
||||
不要用 `stream:false` 测试流式问题。使用:
|
||||
|
||||
```bash
|
||||
KEY='<从本机安全环境读取,不写入文档>'
|
||||
TOOL='{"type":"function","function":{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}'
|
||||
|
||||
curl --no-buffer --max-time 90 \
|
||||
-H "Authorization: Bearer $KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"model\":\"channels-codearts:openpangu-2.0-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Use the weather tool for Beijing.\"}],\"tools\":[$TOOL],\"tool_choice\":\"auto\",\"stream\":true,\"max_tokens\":64}" \
|
||||
http://127.0.0.1:8081/v1/chat/completions
|
||||
```
|
||||
|
||||
分别将 model 替换为:
|
||||
|
||||
```text
|
||||
channels-codearts:openpangu-2.0-flash
|
||||
channels-trae:DeepSeek-V4-Flash
|
||||
channels-buddy:glm-5.3-flash
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
- HTTP 200
|
||||
- `Content-Type: text/event-stream`
|
||||
- 不能出现 `tool_calls: {}`
|
||||
- 有 tool-call fragments 时最终必须是 `finish_reason: tool_calls`
|
||||
- CodeArts 当前应继续返回结构化工具调用
|
||||
|
||||
### 第五步:CodeArts 刷新验收
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer $KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"platform":"codearts"}' \
|
||||
http://127.0.0.1:8081/api/plugins/http/channels/model/refresh
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
```json
|
||||
{"ok":true,"results":[{"platform":"codearts","ok":true,"count":11}]}
|
||||
```
|
||||
|
||||
再检查:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer $KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"platform":"codearts"}' \
|
||||
http://127.0.0.1:8081/api/plugins/http/channels/model/list
|
||||
```
|
||||
|
||||
预期:远端目录 11 个 + 兜底补位,已确认 404 的 4 个仍保持 `disabled=true`。
|
||||
|
||||
## 8. 发布/安装包状态
|
||||
|
||||
目前没有完成“全部安装包”发布闭环。
|
||||
|
||||
已经发生的事情:
|
||||
|
||||
- 核心包构建成功,但脚本实际使用了 `git describe`,产物版本类似:
|
||||
`2.0.40~4-gc9f3453`
|
||||
- Linux GUI 包构建成功,版本为 `1.9.7`
|
||||
- Windows 主机 Wine 构建失败,生成了一个 264KB 的坏 `ModelRouter Setup 1.9.7.exe`,绝对不能发布
|
||||
- Docker Windows 构建也失败在拉取 Docker Hub 基础镜像 `golang:1.25` 网络超时
|
||||
- apt 无法找到 `wine32:i386`
|
||||
- 旧的 1.9.5/1.9.6 Windows 包是历史产物,不能作为当前版本
|
||||
|
||||
不要把旧 Windows exe 当作新包发布。
|
||||
|
||||
仓库已有 Docker 方案:
|
||||
|
||||
```text
|
||||
cmd/gui/scripts/dist-win-docker.sh
|
||||
cmd/gui/docker/win-builder/Dockerfile
|
||||
```
|
||||
|
||||
但当前 Docker Hub 网络不可用。若要继续做 Windows 包:
|
||||
|
||||
1. 在可访问 Docker Hub 的构建机/CI 执行 Docker 方案;或
|
||||
2. 提供包含 Wine32/Wine64 的完整构建环境;或
|
||||
3. 暂时只发布 Linux/core 包,并明确说明 Windows 包未完成。
|
||||
|
||||
版本脚本注意:
|
||||
|
||||
- 核心包脚本使用位置参数:
|
||||
|
||||
```bash
|
||||
./packaging/core-dist.sh 2.0.41
|
||||
```
|
||||
|
||||
不是 `VERSION=2.0.41 ./packaging/core-dist.sh`。
|
||||
- GUI 版本来自 `cmd/gui/package.json`,环境变量 `VERSION` 不会自动修改 Electron 产品版本。
|
||||
- 清理旧产物后再打包,并在全部包完成后统一计算 `SHA256SUMS`。
|
||||
|
||||
## 9. 参考插件状态
|
||||
|
||||
用户要求按:
|
||||
|
||||
```text
|
||||
https://github.com/Ebony-Vinyl/dsh-our-free-model
|
||||
```
|
||||
|
||||
逐厂商对照参考插件。
|
||||
|
||||
当前本地路径:
|
||||
|
||||
```text
|
||||
/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model
|
||||
```
|
||||
|
||||
但该目录当前不是有效可读源码副本:
|
||||
|
||||
- `vendor/channel-pack/src` 下面只有空目录;
|
||||
- `.git` 没有有效 `HEAD`/refs;
|
||||
- 无法从本地 Git 对象恢复源码;
|
||||
- 本地也没有 `buddy.ts`、`trae.ts`、`codearts.ts` 等文件。
|
||||
|
||||
因此不能声称当前适配器是逐行照参考插件移植的。若要继续按用户要求完成,必须先重新 clone/恢复该 GitHub 仓库源码,确认以下文件实际存在后再对照:
|
||||
|
||||
```text
|
||||
vendor/channel-pack/src/trae.ts
|
||||
vendor/channel-pack/src/buddy.ts
|
||||
vendor/channel-pack/src/codearts.ts
|
||||
vendor/channel-pack/src/*adapter*
|
||||
vendor/channel-pack/src/*model*
|
||||
```
|
||||
|
||||
当前仓库代码只能作为临时协议修复基础,不能代替参考插件源码。
|
||||
|
||||
## 10. 关键结论
|
||||
|
||||
- Pi 是否发送流式请求,历史日志没有保存入站 `stream/tools` 字段,不能靠旧日志判断。
|
||||
- 本次真实 `stream:true + tools` 测试证明 ModelRouter 流式路径能工作:
|
||||
- CodeArts:通过;
|
||||
- Trae:通过但结束原因需归一;
|
||||
- Buddy:通过但需过滤空 `{}`;
|
||||
- MiniMax:凭据 401,独立问题。
|
||||
- 不要再用 `stream:false` 的手工请求推断 Pi 的问题。
|
||||
- CodeArts 刷新接口当前真实返回 `ok=true,count=11`,刷新链路不是当前阻塞点。
|
||||
- 当前最紧急的代码状态是修复 `provider_test.go` 的半编辑,然后验证未提交的统一流式修复。
|
||||
- 未经测试、未重新构建、未重新部署,不要继续改生产账号或刷新一次性凭据。
|
||||
21
LICENSE
21
LICENSE
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 HeNanMU-EEA
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
14
Makefile
14
Makefile
@ -1,8 +1,8 @@
|
||||
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean gui-test
|
||||
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean
|
||||
|
||||
# ModelRouter - single-binary gateway + optional Electron desktop GUI (embedded core)
|
||||
export BINARY
|
||||
BINARY=modelrouter
|
||||
BINARY=llmsproxy
|
||||
export BUILD_DIR
|
||||
BUILD_DIR=build
|
||||
export GUI_DIR
|
||||
@ -20,7 +20,7 @@ all: build
|
||||
# ---- core binary (server users / embedded core) ----
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=1 go build -tags luajit -trimpath -o $(BUILD_DIR)/$(BINARY) ./cmd/modelrouter
|
||||
CGO_ENABLED=1 go build -tags luajit -trimpath -o $(BUILD_DIR)/$(BINARY) ./cmd/llmsproxy
|
||||
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
|
||||
|
||||
# ---- core distribution packages (deb + rpm + tar.gz) -----
|
||||
@ -65,13 +65,6 @@ gui-dist-dir:
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# Desktop shell unit tests (no Electron needed): plugin-tree seeding/migration.
|
||||
# These guard the two things that silently break the desktop build:
|
||||
# - plugin_dir missing from an embedded profile -> channels plugin invisible
|
||||
# - bundled plugin files never refreshed -> old channels.lua survives upgrades
|
||||
gui-test:
|
||||
@cd $(GUI_DIR) && node --test plugin-sync.test.js
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) cmd/build cmd/gui/dist cmd/gui/build/gui-dist cmd/gui/bin
|
||||
|
||||
@ -82,7 +75,6 @@ help:
|
||||
@echo " make gui run desktop GUI (dev)"
|
||||
@echo " make gui-dev run GUI with devtools"
|
||||
@echo " make gui-dist build deb + rpm + AppImage (linux)"
|
||||
@echo " make gui-test desktop shell unit tests (plugin tree seeding)"
|
||||
@echo " make gui-deb build deb only"
|
||||
@echo " make gui-win build Windows nsis (needs host mingw + wine)"
|
||||
@echo " make gui-win-docker build Windows nsis all-in-docker (no host mingw needed)"
|
||||
|
||||
549
README.md
549
README.md
@ -1,51 +1,103 @@
|
||||
# ModelRouter
|
||||
|
||||
> **仓库**: <https://atomgit.com/HeNanMU-EEA/ModelRouter>
|
||||
>
|
||||
> **English**: [README_EN.md](./README_EN.md)
|
||||
>
|
||||
> **AI-assisted**: 本项目使用 AI 辅助编程(代码与文档由 AI 协作完成,经人工复核)。
|
||||
|
||||
面向多 LLM 订阅者的内网网关:一次配置,多个服务共用一组上游额度。支持指定模型或
|
||||
`AUTO` 模式,按配置的优先级选择可用模型提供服务。
|
||||
面向多 llms 订阅者,部署在内网,实现一次配置多个服务共同使用的效果。支持指定模型或 auto 模式,按照配置的优先级选择可用模型提供服务。
|
||||
|
||||
网关本体是单个 Go 二进制(约 10 MB,零运行时依赖)。新增或切换上游只需在
|
||||
`config.yaml`(或 WebUI)加一个 `sources` 条目、或挂一个 `.lua` 适配器——**不改 Go、
|
||||
不重编译**。源与适配器的改动经 WebUI 提交时即时生效(热更新);直接编辑
|
||||
`config.yaml` 或 `adapter_dir` 下的 `.lua` 文件需要重启进程生效。
|
||||
> **轻量且增源无需重编译**:网关本体是单 Go 二进制(约 10MB,零运行时依赖)。新增/切换上游
|
||||
> 只需在 `config.yaml`(或 WebUI)加一个 `sources` 条目或挂一个 `.lua` 适配器——**不改 Go、
|
||||
> 不重编译**。源与适配器的改动经 WebUI 提交时即时生效(热更新);直接编辑
|
||||
> `config.yaml` 或 `adapter_dir` 下的 `.lua` 文件则需要重启进程生效。
|
||||
|
||||
## 核心能力
|
||||
## 核心优势
|
||||
|
||||
- **统一接口**:对内暴露 OpenAI 兼容的 `/v1/chat/completions`、`/v1/models`、
|
||||
`/v1/images/generations`;所有源对外都是同一套格式(含 `reasoning_content`、
|
||||
`tool_calls`、`usage`)。
|
||||
- **多源 + AUTO 链**:一个进程内任意多个上游源;`default_model: AUTO` 时按优先级页
|
||||
保存的档位逐档调度,源故障自动降级并冷却,恢复后半冷却探测自愈。
|
||||
- **协议适配在 Lua 层**:每个源挂一个 `.lua` 适配器,完成请求/响应/流式分片的双向
|
||||
转换,并可用 `build_headers(meta)` 注入签名头。协议差异不进 Go 内核。
|
||||
- **插件独立**:业务扩展(计费、用量统计等)是外置 Lua 插件,由插件目录加载,
|
||||
不编进二进制、不随发行包分发;插件只通过 `modelrouter` SDK 与内核交互。
|
||||
- **多租户密钥**:网关 key 与上游 key 相互独立,按 key 限定模型范围、AUTO 链与配额。
|
||||
- **加密存储**:上游 key、自定义请求头、网关 key 均以 AES-256-GCM 落盘,
|
||||
`master.key` 单独 0600 权限(或由 `MODELROUTER_MASTER_KEY` 注入)。
|
||||
- **可观测**:状态页实时探测源可达性;HTTP 访问日志、配置变更审计、请求与用量 CSV 导出;
|
||||
写操作不落在请求热路径上。
|
||||
### 极致轻量
|
||||
|
||||
- **单二进制**:编译后 8~12MB(`-s -w` strip 后约 8MB),零运行时依赖(仅依赖系统 libc),部署即用
|
||||
- **低内存占用**:与源数量相关,非与运行时长相关——单源 ~10MB、16 源生产实例 ~32–35MB([实测分解与调优](#内存占用实测与调优))
|
||||
- **日志按需加载**:统计数字来自**全量**审计日志(启动时流式扫一遍即释放,29MB/22 万行约 260ms),但原始记录不常驻内存——默认只加载首屏,下滚自动分页,CSV 导出流式写出(O(1) 内存),离页即释放
|
||||
- **零运行时依赖**:纯 Go + LuaJIT 静态链接,无需安装 Python/Node/Java 等运行时
|
||||
- **启动极快**:冷启动 < 200ms,热重载配置 < 10ms
|
||||
|
||||
### 强大的多租户调度能力
|
||||
|
||||
- **多密钥多租户**:支持无限密钥,每个密钥独立角色、模型范围、Token 配额、重置周期
|
||||
- **AUTO 智能调度**:基于优先级档位的分级调度,同优先级源自动轮询负载均衡,故障自动毫秒级故障转移
|
||||
- **自愈冷却**:冷却上限 5 分钟,过半后放行 1 个探测请求,上游/额度恢复即刻回归轮询,无需等满冷却窗口
|
||||
- **Token 配额管理**:精确到模型级别的 Token 配额控制,支持小时/周/月/自定义小时周期自动重置
|
||||
- **同优先级源负载均衡**:同一优先级档位的多个源,请求自动 Round-Robin 均匀分发,故障毫秒级故障转移
|
||||
- **Source-Model 前缀路由**:支持 `source-model`/`source:model`/`source/model` 精确指定上游源
|
||||
|
||||
### 生产级可靠性
|
||||
|
||||
- **热加载配置**:WebUI 修改源/密钥/适配器/AUTO链即时生效,无需重启
|
||||
- **密钥加密落盘**:AES-256-GCM 加密存储 `api_key`、网关密钥、请求头,master.key 0600 权限保护
|
||||
- **实时源探测**:`GET {base}/models` 定期探测,毫秒级感知上游状态,不污染调度退避状态
|
||||
- **全链路审计**:HTTP 访问日志、登录/配置变更审计、请求记录 CSV 导出、密钥用量统计 CSV 导出
|
||||
|
||||
### 灵活的协议适配
|
||||
|
||||
- **LuaJIT VM**:每个适配器独立 VM + **弹性 worker 池**(按需扩缩容,空闲时自动回收),安全并发,Lua 脚本热加载无需重启
|
||||
- **Lua 适配器协议**:`transform_request` / `transform_response` / `transform_stream_chunk` 双向转换
|
||||
- **动态请求头钩子**:`build_headers(meta)` 支持 HMAC 签名、动态 Header 注入
|
||||
- **多模态透传**:`image_url` 等多模态内容在多源间无损透传,Anthropic/Gemini/Ollama 自动转换
|
||||
|
||||
## 实现
|
||||
|
||||
统一 OpenAI 兼容网关:把多个上游 LLM 源(DeepSeek、Qijiar、OpenAI、Anthropic、Gemini、
|
||||
Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换,对内网暴露
|
||||
一个标准 `OpenAI Chat Completions` 接口(`/v1/chat/completions` + `/v1/models`),
|
||||
支持单次结算、SSE 流式、**AUTO 模型路由**与**多模态**透传。
|
||||
|
||||
从 [HomeAgent](https://gitcode.com/JianFeeeee/HomeAgent) 的多源 LLM 适配层
|
||||
(`internal/agent/api/provider.go` + `internal/lua/adapters/*`)抽离而来并独立演进。
|
||||
|
||||
## 特性
|
||||
|
||||
- **多源**:一个进程内配置任意多个上游源,按请求的 `model` 自动路由。
|
||||
- **AUTO 模式**:`default_model: AUTO` 时按优先级页保存的 AUTO 链档位逐档调度并发请求。
|
||||
- **统一输出**:所有源对外都是 OpenAI 格式(含 `reasoning_content`、`tool_calls`、`usage`)。
|
||||
- **生图**:`POST /v1/images/generations`,`kind: image` 的模型独立路由。
|
||||
- **多模态**:`content` 数组(`image_url` 等)在多源间无损透传,Anthropic/Gemini/Ollama 自动转换。
|
||||
- **LuaJIT VM**:基于 golua 绑定的 LuaJIT,每个适配器独立 VM + worker 池,安全并发。
|
||||
池是 **弹性** 的:`Σ max_concurrent` 只是上限而非预分配,状态按需创建、空闲时自动回收,
|
||||
因此空载网关几乎不持有 Lua 状态(启动 0 个)。
|
||||
- 扩容步长由适配器最大并发决定:`clamp(ceil(max/8), 1, 8)`,且仅在**真实并发争用**
|
||||
(所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。
|
||||
- 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到
|
||||
常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。
|
||||
- **disable_thinking**:请求 `disable_thinking:true`(或上游对应字段)动态开关推理。
|
||||
- **Lua 适配协议**:每个源挂一个 `.lua` 适配器,完成 `transform_request` /
|
||||
`transform_response` / `transform_stream_chunk` 双向转换,协议差异全在 Lua 层。
|
||||
- **签名 / 请求头钩子**:适配器可定义 `build_headers(meta)`,在 Go 发 HTTP 前
|
||||
动态注入/签名请求头——用于云端 API 校验调用方 app(如 KimiCode 只放行特定
|
||||
agent)。提供 `hmac_sha256_hex` / `sha256_hex` / `base64_encode` 等签名辅助。
|
||||
- **WebUI**:内置管理界面,可在线查看/新增/编辑上游源与模型、配置 AUTO 优先级链、
|
||||
管理密钥模型范围,写入运行时文件持久化。
|
||||
- **密钥加密存储**:运行时文件中的上游 `api_key`、自定义请求头值、网关 key 均以
|
||||
AES-256-GCM 加密落盘(`master.key` 独立 0600,或 `LLMS_PROXY_MASTER_KEY`)。
|
||||
- **实时源探测**:状态页探测各源可达性(`GET {base}/models`,失败回退最小请求),
|
||||
不污染正常调度的退避状态,错误信息在 UI 可悬停查看。
|
||||
- **鉴权**:网关自身用 `gateway_keys` 校验客户端 Bearer key;与上游各自的 key 相互独立。
|
||||
- **流式**:SSE `chat.completion.chunk`,含角色首包与 `[DONE]` 收尾。
|
||||
- **全链路审计**:HTTP 访问日志、登录/配置变更审计、请求记录 CSV 导出、密钥用量统计 CSV 导出
|
||||
- **AES-256-GCM 加密存储**:运行时文件敏感字段加密落盘,master.key 0600 权限,支持环境变量注入主密钥
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 需要 LuaJIT 开发库;-tags luajit 是必须的
|
||||
GOMODCACHE=... GOPROXY=off go build -tags luajit -o modelrouter ./cmd/modelrouter
|
||||
|
||||
# 首次运行会生成默认配置,并在日志里打印随机 admin key
|
||||
./modelrouter -config config.yaml
|
||||
GOMODCACHE=... GOPROXY=off go build -tags luajit -o llmsproxy ./cmd/llmsproxy
|
||||
./llmsproxy -config config.yaml # 首次运行自动生成默认配置并打印随机 admin key
|
||||
```
|
||||
|
||||
> 依赖 [golua](https://github.com/aarzilli/golua)(LuaJIT 绑定)。不带 `-tags luajit`
|
||||
> 会走 gopher-lua 路径,行为略有差异。
|
||||
> 依赖 [golua](https://github.com/aarzilli/golua)(LuaJIT 绑定)。**必须**带 `-tags luajit`
|
||||
> 构建,否则默认走内置超集 gopher-lua 路径(行为略有差异)。
|
||||
|
||||
> **配置不入库**:仓库不携带任何 config 文件(其中含密钥)。默认配置只绑定
|
||||
> `127.0.0.1:8080`,首次登录后请在 WebUI「密钥」页更换管理员密钥。
|
||||
> **配置不入库**:仓库不携带任何 config 文件(配置文件含密钥)。二进制首次运行时
|
||||
> 会在 `-config` 指定的位置生成默认配置:随机 admin key(打印在启动日志中)、
|
||||
> 仅绑定 `127.0.0.1:8080`。首次登录后请在 WebUI「密钥」页更换管理员密钥。
|
||||
|
||||
```bash
|
||||
# 无 key -> 401
|
||||
@ -62,77 +114,396 @@ curl -N -H "Authorization: Bearer $KEY" \
|
||||
http://127.0.0.1:8080/v1/chat/completions
|
||||
```
|
||||
|
||||
任何 OpenAI SDK 把 `base_url` 指向网关、`api_key` 换成任一已授权的网关 key 即可。
|
||||
任何 OpenAI SDK 把 `base_url` 指到网关地址、`api_key` 用任意一个已创建并授权的网关 key 即可。
|
||||
|
||||
## 文档
|
||||
## 配置
|
||||
|
||||
| 主题 | 文档 |
|
||||
|---|---|
|
||||
| 实现与完整特性清单 | [docs/features.md](docs/features.md) |
|
||||
| 配置:`config.yaml`、自动探测模型、多密钥 | [docs/configuration.md](docs/configuration.md) |
|
||||
| 路由:模型路由、AUTO 链、冷却与半冷却、源与适配器、源模板 | [docs/routing.md](docs/routing.md) |
|
||||
| 适配器:Lua 协议、内置适配器清单 | [docs/adapters.md](docs/adapters.md) · [docs/lua-adapters.md](docs/lua-adapters.md)([EN](docs/lua-adapters-en.md)) |
|
||||
| 插件:SDK 契约、钩子、UI 扩展、HTTP 端点 | [docs/plugins.md](docs/plugins.md) |
|
||||
| 桌面端:Headless / Desktop 两种产物、构建与运行 | [docs/gui.md](docs/gui.md) |
|
||||
| 性能:实测内存占用与调优、thinking 预算 | [docs/performance.md](docs/performance.md) |
|
||||
| WebUI 使用 | [docs/webui.md](docs/webui.md) |
|
||||
| 开发:目录职责、测试、分支与发布纪律 | [docs/development.md](docs/development.md) · [docs/git-workflow.md](docs/git-workflow.md)([EN](docs/git-workflow-en.md)) |
|
||||
| 部署与迁移 | [docs/deployment.md](docs/deployment.md) |
|
||||
首次运行在 `-config` 路径自动生成默认配置(默认 `./config.yaml`),核心字段:
|
||||
|
||||
## 从 llmsproxy 改名
|
||||
```yaml
|
||||
listen: 127.0.0.1:8080 # 网关监听地址(建议绑内网/回环)
|
||||
gateway_keys: [sk-gw-<随机>] # 初始 admin 密钥(每次安装随机生成,仅首启写入运行时存储用)
|
||||
default_model: AUTO # model 无法路由时自动按优先级链选源
|
||||
adapter_dir: adapters # Lua 适配目录;目录不存在时首启创建并 seed 内置,存在则只读
|
||||
runtime_file: runtime.json # WebUI 编辑的源/密钥/AUTO 链持久化到此文件
|
||||
|
||||
产品名早已是 ModelRouter,二进制、配置目录、服务名与环境变量现已统一为
|
||||
`modelrouter`:
|
||||
|
||||
| 旧 | 新 |
|
||||
|---|---|
|
||||
| `llmsproxy`(二进制) | `modelrouter` |
|
||||
| `/etc/llmsproxy/` | `/etc/modelrouter/` |
|
||||
| `llmsproxy.service` | `modelrouter.service` |
|
||||
| `LLMSPROXY_*` | `MODELROUTER_*` |
|
||||
|
||||
已部署的机器用迁移脚本升级(**默认 dry-run**,复制不移动,可回滚):
|
||||
|
||||
```bash
|
||||
sudo ./packaging/migrate-to-modelrouter.sh # 预演
|
||||
sudo ./packaging/migrate-to-modelrouter.sh --apply # 执行
|
||||
sudo ./packaging/migrate-to-modelrouter.sh --rollback --apply
|
||||
sources:
|
||||
- name: deepseek
|
||||
base_url: https://api.deepseek.com
|
||||
api_key: sk-... # 也可用 api_key_env: SOME_ENV 引用环境变量(不落盘明文)
|
||||
adapter: deepseek
|
||||
max_concurrent: 8
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
priority: 100 # YAML 源首次启动会 seed 进 AUTO 链的初值
|
||||
kind: chat
|
||||
- id: deepseek-v4-pro
|
||||
priority: 60
|
||||
kind: chat
|
||||
# 静态请求头(比适配器默认优先)
|
||||
headers: { X-Tenant: prod }
|
||||
# 传给 Lua build_headers 的透传元数据
|
||||
meta: { app_id: x, app_secret: y }
|
||||
temperature: 0.7
|
||||
max_tokens: 4096
|
||||
timeout: 120s # 请求超时,默认 120s
|
||||
```
|
||||
|
||||
旧路径与旧环境变量在读取侧仍被接受,因此升级不会因为顺序问题立刻失效。
|
||||
`deploy.sh` 在检测到旧 `/etc/llmsproxy` 而新配置不存在时会**中止**,避免部署出
|
||||
一个「能启动但没有 key / 没有源」的空实例。
|
||||
`config.yaml`、`runtime.json`、`master.key`、`adapters/` 均不提交版本库(见
|
||||
`.gitignore`),请随备份带走——`master.key` 丢失后运行时密文无法解密。
|
||||
|
||||
插件 SDK 同名调整:唯一公开入口是 `modelrouter`(`llmsproxy` 仅作兼容别名),
|
||||
详见 [docs/plugins.md](docs/plugins.md)。
|
||||
运行文件生成在 `-config` 所在目录:`adapter_dir` / `runtime_file` 自动指向
|
||||
配置文件旁边的 `adapters/` 与 `runtime.json`,从任意工作目录启动都可用。
|
||||
|
||||
## 目录结构
|
||||
运行时文件(`runtime_file`)中的敏感字段自动加密:
|
||||
|
||||
```text
|
||||
cmd/modelrouter # 入口(纯 Go 服务端)
|
||||
cmd/gui # Electron 桌面壳(内嵌同一份核心)
|
||||
internal/config # 配置解析、加密存储、运行时文件
|
||||
internal/core # 调度、内核动作、状态与审计
|
||||
internal/lua # LuaJIT VM + 弹性 worker 池 + 内置适配器 (embed)
|
||||
internal/lua/plugins # 可选插件示例(不随二进制/发行包分发)
|
||||
- 加密算法 AES-256-GCM,格式 `enc:v1:<base64>`。
|
||||
- 主密钥来源:环境变量 `LLMS_PROXY_MASTER_KEY`(64 位 hex);否则读取
|
||||
`runtime_file` 同目录的 `master.key`;都不存在时首次启动自动生成 `master.key`(0600)。
|
||||
- 升级时旧明文文件自动兼容:首次运行正常读取,任何 UI 保存操作触发全文件加密迁移。
|
||||
- 注意:**master.key 丢失后密文无法解密**,请随配置一起备份;切勿提交到版本库。
|
||||
|
||||
### 网关密钥(多密钥)
|
||||
|
||||
网关鉴权采用「多密钥 + 角色 + 模型范围」架构,密钥持久化在 `runtime_file` 的
|
||||
`keys` 字段(加密存储):
|
||||
|
||||
- `gateway_keys` 配置只是**初始 admin 密钥种子**:首次启动迁移为运行时 admin
|
||||
key,之后不再参与鉴权管理。
|
||||
- **重要:首次启动后请在 WebUI「密钥」页更换管理员密钥**——初始密钥明文写在
|
||||
`config.yaml` 里(每次安装随机生成),继续使用存在被盗风险;用新密钥登录后删除初始密钥。
|
||||
- WebUI **密钥页**可创建/删除密钥;每个密钥可指定 `admin`(管理全部)或
|
||||
`user`(仅看自己的 key)角色,并配置**模型范围**(模型 + 源 + token 配额 +
|
||||
重置周期)。
|
||||
- 客户端用任意一个已授权的密钥明文作为 Bearer(`Authorization: Bearer <key>`)。
|
||||
- 删除密钥即从运行时存储移除,立即失效。
|
||||
|
||||
### 模型路由
|
||||
|
||||
`/v1/chat/completions` 的 `model` 解析顺序:
|
||||
|
||||
1. `source/model` 或 `source:model` 前缀 → 指定源;
|
||||
2. 精确匹配某个源的 `model`;
|
||||
3. 配置 `default_model: AUTO` 时 → 走优先级页保存的 AUTO 链(见下);
|
||||
4. 否则回落(返回错误)。
|
||||
|
||||
任何 OpenAI 客户端,只要 `model` 设为某个源的 `name/任意名`,即可锁定走该源;
|
||||
设为 `AUTO`(或网关配了 `default_model: AUTO`)即自动按优先级链选源。
|
||||
|
||||
### AUTO 链(优先级页)
|
||||
|
||||
AUTO 调度**只**由优先级页保存的规则决定,源配置里的 `models[].priority` 数字不再
|
||||
参与调度、也不再显示。**聊天与生图是两套独立的链**,在优先级页面通过「聊天 / 生图」
|
||||
按钮切换编辑:
|
||||
|
||||
- **聊天链**(`runtime_file` 的 `auto` 字段):服务 `POST /v1/chat/completions`
|
||||
的 `model: AUTO` 请求;只接受 `kind: chat` 模型,配置在链里的生图槽位会被忽略。
|
||||
- **生图链**(`runtime_file` 的 `auto_image` 字段):服务 `POST /v1/images/generations`
|
||||
的 `model: AUTO` 请求;只接受 `kind: image` 模型。未配置生图链时,AUTO 生图回落
|
||||
到「所有含生图模型的源、按注册顺序」的旧行为,兼容旧配置。
|
||||
|
||||
每条规则 = 一个「槽位」:`{ model, source, tier, token_quota, period, hours }`。
|
||||
|
||||
- `tier` 表示优先级档位:同一档的模型并排、共享该优先级;档位从上到下递减。
|
||||
- 同一模型可配置多个槽位(如 A 源低配 → B 源低配 → A 源高配 → B 源高配),按档位顺延。
|
||||
- `token_quota` > 0 时该槽位在重置周期内用满即顺延到下一槽位;`period` 支持
|
||||
`hour` / `week` / `month` / `nhour`(配合 `hours`),空 = 不限。
|
||||
- 同 tier 内按「偏好分」排序,失败槽位冷却后自动跳过;冷却 / 配额耗尽 / hard
|
||||
错误会顺延到下一 tier,全部失败时返回 503 并附上每档失败摘要。
|
||||
|
||||
### 冷却与半冷却探测(自愈调度)
|
||||
|
||||
槽位失败后进入指数退避冷却(5s → 10s → 20s …,上限 **5 分钟**)。冷却不是全时段
|
||||
硬闸:
|
||||
|
||||
- **前半段完全静默**:刚失败的槽位不接任何流量,避免对故障上游持续施压。
|
||||
- **后半段放行 1 个探测**:窗口过半后,同一 `(源, 模型)` 最多允许 **一个** 请求作为
|
||||
探测通过。探测槽数 = `clamp(max_concurrent / 10, 1, 2)`——`max_concurrent: 10`
|
||||
的源恰好放行 1 个探测请求。
|
||||
- **探测排在最后**:同 tier 内探测候选永远排在健康候选之后,只有没有正常槽可用时
|
||||
才承接真实流量,因此探测不会抢走可用容量。
|
||||
- **探测即真实请求**:成功则立即清零失败计数与冷却,槽位当场回到正常轮询——
|
||||
上游恢复/额度恢复后不必等满整个冷却窗口。失败则重开一个新窗口,下次探测顺延到
|
||||
新窗口的中点,不会连续重试。
|
||||
|
||||
分类冷却策略:
|
||||
|
||||
| 情况 | 冷却 | 说明 |
|
||||
|---|---|---|
|
||||
| 传输错误 / 5xx | 5s→10s→20s…,上限 5min | 指数退避 |
|
||||
| 401 / 403 凭据错误 | 10min | 换 key 后由下一次探测自动接回 |
|
||||
| 429 限流 | 30s 固定 | 「太快」不等于「坏了」,不进指数阶梯 |
|
||||
| 配额耗尽(`insufficient_quota` / `余额不足` 等) | 对齐配额窗口,上限 30min | **不进指数阶梯、不涨失败计数**,额度恢复即可用 |
|
||||
|
||||
WebUI「优先级」页的槽位健康标签会区分 `冷却` / `待探测` / `探测中`,悬停可看窗口
|
||||
起点、探测放行时刻与完全恢复时刻。
|
||||
|
||||
### 源(Source)与适配器(Adapter)的关系
|
||||
|
||||
- **源** 是到某个上游的连接描述:`name`、`base_url`、`api_key`、模型列表与优先级。
|
||||
- **适配器** 是协议转换逻辑(Lua 脚本):把统一 OpenAI 格式请求转成上游原生格式,
|
||||
再把上游响应/流式分块转回统一格式。
|
||||
- 一个适配器可被多个源复用(如 `openai.lua` 同时服务多个 OpenAI 兼容站点);
|
||||
同一个源也可以切换不同适配器(改 `adapter` 字段即可)。
|
||||
- 源决定“连谁、暴露哪些模型”,适配器决定“怎么对话”——二者在 `sources[]` 条目中
|
||||
通过 `adapter` 字段关联。
|
||||
|
||||
加载流程(`internal/core` 的 `Core` 负责装配):
|
||||
|
||||
1. 启动时 `lua.NewVM(adapter_dir)` 加载全部适配器:内置适配器(编译期 embed)+
|
||||
`adapter_dir` 下的同名覆盖文件;内置文件写在代码内,覆盖文件要求更高优先级。
|
||||
2. `config.Load` 读取 `config.yaml`,`config.NewStore(runtime_file)` 读 WebUI 改动的
|
||||
运行时源,二者按名称合并成完整源列表。
|
||||
3. `rebuildRegistry` 为每个源创建 `provider.Provider`(持有目标适配器),并按源的
|
||||
并发上限配置适配器 worker 池**上限**(不预分配,见下)。
|
||||
4. 请求进来时 `Registry` 按 `model` 路由到 Provider,Provider 调适配器
|
||||
`transform_request` → HTTP 发送 → `transform_response` / `transform_stream_chunk`。
|
||||
|
||||
WebUI 上的"新增/编辑源"、"上传 Lua 适配器"、"改 AUTO 优先级链"与"密钥模型范围"
|
||||
都即时生效(写入运行时文件或 `adapter_dir` 后重新装配,无需重启);直接编辑
|
||||
`config.yaml` / `adapter_dir` 下的文件则需要重启进程才会重新加载。
|
||||
|
||||
在 WebUI 删除源/适配器时:运行时(WebUI 创建)的源与 `.lua` 文件会真正移除;
|
||||
`config.yaml` 中定义的基源无法改写配置文件,采用删除标记隐藏(重启后仍隐藏),
|
||||
在 UI 里重新添加同名源即可恢复。
|
||||
|
||||
### 源模板(多 key 负载均衡)
|
||||
|
||||
多个上游 key 共享同一套配置(URL / 适配器 / 模型列表 / 并发 / rpm)时,逐一复制
|
||||
整段 source 太冗余。**源模板**保存除 `name` 和 `api_key` 以外的全部源字段,
|
||||
从模板一键创建多个仅 key 不同的源,天然支持多 key 负载均衡(每个展开源独立调度、
|
||||
独立冷却、独立健康状态)。
|
||||
|
||||
- WebUI「源」页右上角「**模板管理**」按钮:列出全部模板,可编辑 / 删除 / 新建。
|
||||
- 添加源弹窗右上角两个按钮:
|
||||
- **从模板创建** → 选模板 → 表单自动填充(name + key 仍需你填);
|
||||
- **存为模板** → 把当前表单非 key/name 字段存为新模板。
|
||||
- 模板持久化在 `runtime_file` 的 `source_templates` 字段,与运行时源一样热管理,
|
||||
改完即时生效。模板本身不含密钥,可以安全共享 / 纳管。
|
||||
|
||||
模板不会自己变成源——它只是「配方」。真正承担流量的是从模板创建出来的、带 key
|
||||
的具体源。
|
||||
|
||||
### 内存占用(实测与调优)
|
||||
|
||||
内存不是一个定数:它随**配置的源数量**增长(每个源一个 `http.Transport` 与连接池、
|
||||
一组健康状态),与运行时长无关。本机实测(Linux x86_64,12 核):
|
||||
|
||||
| 部署形态 | 启动 RSS | 稳态 RSS |
|
||||
|---|---|---|
|
||||
| 1 源 / 1 适配器(最小配置) | ~4 MB | ~10 MB |
|
||||
| 1 源 + 29 MB 历史审计日志 | ~19 MB | ~20 MB |
|
||||
| **16 源 / 13 适配器 / 59 模型(本机生产)** | ~28 MB | **~32–35 MB** |
|
||||
|
||||
> 历史参考:本项优化前同一生产配置为 **~105 MB**。降幅来自三处:审计日志的**原始记录**
|
||||
> 不再常驻内存(约 25 MB;统计聚合仍扫全量,但扫完即释放)、Lua 状态池不再单调增长、
|
||||
> 以及下面两个运行时开关。
|
||||
|
||||
内存构成(生产实例分段测量,`/proc/<pid>/smaps`):
|
||||
|
||||
| 区域 | RSS | 说明 |
|
||||
|---|---|---|
|
||||
| Go 堆 | ~14 MB | provider/registry/scheduler 结构 + 连接池缓冲 |
|
||||
| 其他匹名(线程栈 / LuaJIT chunk / runtime) | ~12 MB | 与线程数、已加载适配器数相关 |
|
||||
| 二进制 text+rodata | ~8 MB | 映射的可执行文件页(只读、可被内核回收) |
|
||||
| Go runtime 预留 | ~4 MB | VSZ 上看到的 2 GB+ 是地址空间预留,不占物理内存 |
|
||||
| 共享库 | ~3 MB | libc / libluajit / libm |
|
||||
|
||||
**两个推荐的部署开关**(只适用于环境变量,无需改代码):
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/llmsproxy.service
|
||||
Environment=GOGC=50
|
||||
Environment=MALLOC_ARENA_MAX=2
|
||||
```
|
||||
|
||||
- `MALLOC_ARENA_MAX=2`:LuaJIT 的分配走 cgo → glibc malloc,glibc 默认允许 `8×nproc`
|
||||
个 per-thread arena,每个碰到 malloc 的 OS 线程会占用一个(各约 1 MB,且**不归还给系统**)。
|
||||
实测从 8–15 个 arena(约 7–12 MB)降到 **0**。
|
||||
- `GOGC=50`:把 Go 堆增长目标减半。**单独使用无效**(省下的堆会立即被更多 glibc
|
||||
arena 吃掉,实测 20.3 → 21.5 MB 反而变大),必须与 `MALLOC_ARENA_MAX` 配合,
|
||||
两者同时开启才降 **~19%**。网关是 I/O 密集型(本机 9 小时仅消耗 1min10s CPU),
|
||||
多出的 GC 周期与它的空闲 CPU 相比可忽略。
|
||||
|
||||
> 桌面版(Electron)已在 `cmd/gui/main.js` 里默认为内嵌核心注入这两个开关;手动 export 同名
|
||||
> 环境变量可覆盖。如果你用自己的 systemd unit / 容器,建议照上面加上。
|
||||
|
||||
可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进,
|
||||
温和场景不必开。
|
||||
|
||||
### disable_thinking
|
||||
|
||||
请求体带 `"disable_thinking": true` 时,网关透传给各适配器;DeepSeek 适配器将其
|
||||
映射为 `extra_body.thinking.type = "disabled"` 关闭推理,其余源按各自协议处理。
|
||||
|
||||
### WebUI
|
||||
|
||||
内置管理界面(`GET /`),登录后可在浏览器完成:
|
||||
|
||||
- **状态页**:源在线状态(实时探测 + 悬停看错误)、模型/源/key 用量统计、请求记录,
|
||||
支持按时间范围导出 CSV;点击模型可生成 pin 到该模型的连接配置。
|
||||
- **对话页**:流式/非流式调试。
|
||||
- **密钥页**:创建/编辑网关 key,为每个 key 配模型范围(模型 + 源 + token 配额 + 周期),
|
||||
管理员管理全部 key,用户只看到自己的 key。
|
||||
- **优先级页**:拖拽积木配置 AUTO 链档位。
|
||||
- **源页**:在线增删改上游源(API key 等敏感字段加密落盘)。
|
||||
- **适配器页**:上传 / 删除 Lua 适配器脚本。
|
||||
|
||||
改动写入 `runtime_file`(重启仍生效)。
|
||||
|
||||
## Lua 适配器协议
|
||||
|
||||
完整 API 见 **[Lua 适配器 API 文档](docs/lua-adapters.md)**([English](docs/lua-adapters-en.md))。
|
||||
|
||||
每个适配器是一个返回 table 的 Lua 脚本(`internal/lua/adapters/<name>.lua`),
|
||||
加载时可被 `adapter_dir` 下的同名脚本覆盖——**改 Lua 脚本无需重编译**,
|
||||
重启即生效;通过 WebUI 上传的适配器与在线编辑的源配置则即时生效。
|
||||
|
||||
```lua
|
||||
return {
|
||||
name = "mysrc",
|
||||
version = "1.0.0",
|
||||
endpoint = "/chat/completions", -- 上游路径(可被 source.endpoint 覆盖)
|
||||
headers = { ["X-Static"] = "v" }, -- 静态默认请求头(build_headers 缺省时使用)
|
||||
|
||||
-- 请求转换:把 OpenAI 格式 req 转成上游原生格式,返回字符串
|
||||
transform_request = function(raw_json) ... end,
|
||||
|
||||
-- 响应转换:把上游原生响应转成统一格式字符串
|
||||
-- { content, reasoning_content, finish_reason, token_usage{...}, tool_calls[{...}] }
|
||||
transform_response = function(raw_json) ... end,
|
||||
|
||||
-- 流式分块转换:把上游 SSE data 转成 { content, done, ... },返回 "" 则跳过
|
||||
transform_stream_chunk = function(raw_chunk) ... end,
|
||||
|
||||
-- [可选] 动态请求头/签名钩子
|
||||
-- meta = { url, method, body, api_key, timestamp, source={ name, meta={...} } }
|
||||
build_headers = function(meta) return { ["X-App-Sign"] = sign } end,
|
||||
}
|
||||
```
|
||||
|
||||
内置信号辅助:`hmac_sha256_hex(key, data)`、`sha256_hex(data)`、`base64_encode(s)`、
|
||||
`tohex(s)`、`json.encode/decode`、`log(level, msg)`。
|
||||
|
||||
### 内置适配器
|
||||
|
||||
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`。
|
||||
|
||||
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);若
|
||||
源启用 `disable_thinking`,`deepseek` 适配器会把 `extra_body.thinking.type` 置为
|
||||
`disabled`。
|
||||
|
||||
**kimicode** 是展示 `build_headers` 的样例:云端校验调用方 app,需要按
|
||||
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
|
||||
配好 `sources[].meta.{app_id, app_secret, app_agent}` 即可。
|
||||
|
||||
## 桌面 GUI(Electron)
|
||||
|
||||
可选独立产物,面向内网桌面端用户:**内嵌 ModelRouter 核心**(Clash Verge 形态),
|
||||
一键启停、系统托盘常驻、开机自启、静默启动,内容区一比一内嵌完整 WebUI
|
||||
(状态/对话/密钥/优先级/源/适配器 六个页面,免登录)。服务器用户继续使用纯 Go 二进制。
|
||||
|
||||
### 两种安装包:Headless 与 Desktop
|
||||
|
||||
发布到 Release 的安装包分为两类,面向不同用户:
|
||||
|
||||
- **Headless(服务器版)**:纯核心二进制(无 Electron),配置驱动,适合服务器、
|
||||
容器、systemd 等无人值守场景。发布包含 `deb` + `rpm`(含 systemd unit、内存调优
|
||||
与示例配置)与 `tar.gz`(二进制 + `config.example.yaml` + `adapters/` + README),
|
||||
Windows 为 `.zip`。
|
||||
- **Desktop(桌面版)**:Electron GUI 安装包,内嵌核心,适合个人桌面日常使用。
|
||||
|
||||
### 选择 GUI 还是纯后端
|
||||
|
||||
| 场景 | 推荐 | 理由 |
|
||||
| ------ | ------ | ------ |
|
||||
| 服务器 / 内网网关 / 无人值守常驻 | **Headless**(单二进制) | 极轻量(二进制 ~8MB;RSS 随源数量,单源 ~10MB、16 源 ~32–35MB,见[内存占用](#内存占用实测与调优)),零依赖单进程,直接跑在 systemd/任意容器里,远程管理 |
|
||||
| 个人桌面日常使用 / 多设备内网共享 | **Desktop**(GUI) | 免登录内嵌 WebUI、系统托盘一键启停、开机自启、静默后台,适合不懂命令行的使用者 |
|
||||
| Windows 桌面 | **Desktop** | 纯后端在 Windows 上需自行注册服务,GUI 提供原生托盘/自启体验 |
|
||||
| CI 一键出三平台安装包 | **Desktop 打包脚本** | `make gui-*` 系列出 deb/AppImage/NSIS,可进发行流水线 |
|
||||
|
||||
两者完全同源:GUI 内嵌的就是纯后端的同一份 `llmsproxy` 核心(LuaJIT 版),
|
||||
配置/适配器格式完全一致,可随时互换。
|
||||
|
||||
### 构建与运行
|
||||
|
||||
```bash
|
||||
cd cmd/gui
|
||||
npm install
|
||||
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/llmsproxy)
|
||||
make gui-deb # 本机构建 deb(分享给其它 Linux 用户)
|
||||
make gui-dist # 构建 deb + rpm + AppImage(linux,rpm 需系统 rpmbuild)
|
||||
make core-dist # 构建核心 deb + rpm + tar.gz(服务器版,需 nfpm)
|
||||
make gui-win # 构建 Windows nsis 安装包(本机 mingw + wine)
|
||||
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,含 wine)
|
||||
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
|
||||
```
|
||||
|
||||
产物在 `cmd/build/gui-dist/`:`ModelRouter-<ver>.AppImage`、
|
||||
`modelrouter-gui_<ver>_amd64.deb`、`ModelRouter Setup <ver>.exe`(win);
|
||||
核心包在 `cmd/build/dist/`:`llmsproxy_<ver>_amd64.deb`、`llmsproxy-<ver>.x86_64.rpm`。
|
||||
rpm 需系统 `rpmbuild`。每次打包后都会跑 `packaging/verify-dist.sh` 产物体检
|
||||
(大小下限门禁——1.3.0 曾因宿主 wine 损坏产出 264KB 的残缺 exe 却没有被拦住)。
|
||||
发布时会把产物重命名为 `ModelRouter-Desktop-*`(GUI)与 `ModelRouter-Headless-*`(纯后端)
|
||||
两类上传到 GitCode Release。
|
||||
|
||||
#### Windows 打包(docker 固化方案,推荐)
|
||||
|
||||
`make gui-win-docker` 一步完成:交叉编译核心 + NSIS 打包**全在 docker 内**,宿主机
|
||||
**无需 mingw、无需 wine、无需 node**。镜像内置 wine(含 i386 运行时),修复了
|
||||
此前宿主 wine 损坏(缺 syswow64 → `c0000135`)导致产出 264KB 残缺 exe 的问题。
|
||||
|
||||
- 构建镜像:`cmd/gui/docker/win-builder/Dockerfile`(`golang:1.25` + mingw-w64 +
|
||||
node + wine32/wine64 + 预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。
|
||||
LuaJIT 源码取自 gitcode 镜像(国内可达),github 兜底;Go 依赖走 `https://goproxy.cn`。
|
||||
- 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译 + electron-builder
|
||||
NSIS 全部在容器内执行(`USE_SYSTEM_WINE=true` 走镜像内的 wine);electron-builder
|
||||
的 ~400MB 工具链缓存挂到 docker volume,二次构建增量复用。
|
||||
- 首次运行自动构建 `modelrouter/win-builder` 镜像(约 5–10 分钟);已存在则直接复用。
|
||||
- 打包完成后自动跑 `packaging/verify-dist.sh` 门禁:exe 低于 5MB / deb·rpm·7z 低于
|
||||
10MB 即失败——残缺安装包不可能再溜进 Release。
|
||||
|
||||
```bash
|
||||
# 全 Windows 发布(核心重编 + NSIS):
|
||||
make gui-win-docker
|
||||
```
|
||||
|
||||
### 特性
|
||||
|
||||
- **内嵌核心**:自动拉起捆绑的 `llmsproxy`(LuaJIT 版),配置/密钥/adapter 存于
|
||||
用户数据目录 `<userData>/profile/`,首次运行自动生成随机 admin key 写入 `keys`
|
||||
(非 seed)并自注入,WebUI 免登录、不会提示"更换初始密钥"。端口可在设置中更改(默认 8787)。
|
||||
- **系统托盘**:内核状态点、开机自启/静默启动开关、启停/重启内核、退出。
|
||||
- **静默启动**:`--silent` 参数或设置项,启动仅驻托盘不弹窗;首次运行始终显示窗口。
|
||||
- **开机自启**:Windows/macOS 用 `setLoginItemSettings`;Linux 写
|
||||
`~/.config/autostart/modelrouter-gui.desktop`,静默开启时自动附加 `--silent`。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
cmd/llmsproxy # 入口(纯 Go 服务端)
|
||||
cmd/gui # Electron 桌面 GUI(内嵌核心 + 托盘 + 打包)
|
||||
internal/config # YAML 配置加载/校验
|
||||
internal/lua # LuaJIT VM + worker 池 + AdapterCache + 内置适配器 (embed)
|
||||
internal/provider # Provider(HTTP) + Registry(路由)
|
||||
internal/gateway # OpenAI 兼容 HTTP 服务 + 鉴权 + 流式 + WebUI
|
||||
internal/gateway # OpenAI 兼容 HTTP 服务 + 鉴权 + SDK/流式 + WebUI
|
||||
internal/types # 统一格式 & OpenAI wire 类型
|
||||
packaging/ # deb/rpm/tar 打包、systemd unit、迁移脚本
|
||||
docs/ # 主题文档
|
||||
```
|
||||
|
||||
## 开发与测试
|
||||
## 开发协作
|
||||
|
||||
- **Git 分支工作流**:主分支 / 特性分支 / 发布分支的规范见
|
||||
**[docs/git-workflow.md](docs/git-workflow.md)**([English](docs/git-workflow-en.md))。
|
||||
主线:`main` 永可部署 → 特性合入 `main` → 切 `release/vX.Y.Z` 打 tag 发布 →
|
||||
hotfix 提交发布分支并 cherry-pick 回 `main`。
|
||||
- **Lua 适配器协议**:见 **[docs/lua-adapters.md](docs/lua-adapters.md)**
|
||||
([English](docs/lua-adapters-en.md))。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test -tags luajit ./... # 全量
|
||||
go test -tags luajit ./internal/lua # 单包(LuaJIT 相关改动必跑)
|
||||
go test -tags luajit ./...
|
||||
```
|
||||
|
||||
- 分支与发布纪律见 [docs/git-workflow.md](docs/git-workflow.md)。
|
||||
- 插件放置在 `plugin_dir`,网关启动时**不会**创建、覆盖或删除任何插件文件;
|
||||
升级桌面端也不会动它们。
|
||||
|
||||
## 许可
|
||||
|
||||
MIT License,见 [LICENSE](./LICENSE)。
|
||||
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
|
||||
模型路由、多模态透传与 disable_thinking。
|
||||
|
||||
594
README_EN.md
594
README_EN.md
@ -1,155 +1,529 @@
|
||||
# ModelRouter
|
||||
|
||||
> **Repository**: <https://atomgit.com/HeNanMU-EEA/ModelRouter>
|
||||
>
|
||||
> **中文**: [README.md](./README.md)
|
||||
>
|
||||
> **AI-assisted**: code and documentation were produced with AI assistance and
|
||||
> reviewed by a human.
|
||||
> **AI-assisted**: This project is developed with AI-assisted coding (code and docs are produced in collaboration with AI, reviewed by humans).
|
||||
|
||||
An intranet gateway for people with several LLM subscriptions: configure once and
|
||||
let multiple services share the upstream capacity. Pick a model explicitly, or let
|
||||
`AUTO` choose by priority among the sources that are currently healthy.
|
||||
A lightweight, unified OpenAI-compatible LLM gateway for internal networks —
|
||||
configure once, and let multiple services (agents, SDKs, bots) share many
|
||||
upstreams (DeepSeek, Qijiar, OpenAI, Anthropic, Gemini, Groq, Mistral, Ollama,
|
||||
KimiCode…) behind a single endpoint. Pick a specific model or use **AUTO** mode,
|
||||
which routes to the best healthy upstream by configured priority.
|
||||
|
||||
The gateway itself is a single Go binary (~10 MB, no runtime dependencies). Adding
|
||||
or switching an upstream is a `sources` entry in `config.yaml` (or the WebUI), or a
|
||||
dropped-in `.lua` adapter — **no Go changes, no rebuild**. Changes submitted through
|
||||
the WebUI take effect immediately; editing `config.yaml` or a `.lua` adapter file
|
||||
directly requires a restart.
|
||||
> **Lightweight, no recompile to add sources**: the gateway is a single Go
|
||||
> binary (8-12 MB, ~8 MB stripped; zero runtime dependencies). Adding or switching an upstream is
|
||||
> just a `sources` entry in `config.yaml` (or via the WebUI) or a `.lua`
|
||||
> adapter — **no Go changes, no recompile**. Changes submitted through the
|
||||
> WebUI take effect immediately (hot reload); editing `config.yaml` or a `.lua`
|
||||
> file under `adapter_dir` by hand requires a process restart.
|
||||
|
||||
## What it does
|
||||
## Overview
|
||||
|
||||
- **One OpenAI-compatible API**: `/v1/chat/completions`, `/v1/models`,
|
||||
`/v1/images/generations`. Every source speaks the same shape outwards, including
|
||||
`reasoning_content`, `tool_calls` and `usage`.
|
||||
- **Many sources, one AUTO chain**: configure any number of upstreams; `AUTO`
|
||||
follows the priority tiers saved in the WebUI, fails over between them, cools
|
||||
down broken sources and probes them back to health at half-cool.
|
||||
- **Protocol differences live in Lua**: each source binds a `.lua` adapter that
|
||||
transforms requests, responses and stream chunks both ways, and can sign headers
|
||||
via `build_headers(meta)`. Vendor knowledge stays out of the Go kernel.
|
||||
- **Plugins are independent**: business extensions (channel login, billing, quota
|
||||
probing) are external Lua plugins loaded from `plugin_dir`. They are not compiled
|
||||
into the binary and not shipped in release archives, and they talk to the kernel
|
||||
only through the `modelrouter` SDK.
|
||||
- **Multi-tenant keys**: gateway keys are separate from upstream keys, and can be
|
||||
scoped to a model set, an AUTO chain and a quota.
|
||||
- **Encrypted at rest**: upstream keys, custom header values and gateway keys are
|
||||
sealed with AES-256-GCM; `master.key` is 0600, or injected via
|
||||
`MODELROUTER_MASTER_KEY`.
|
||||
- **Observability**: live source probing, HTTP access log, config-change audit,
|
||||
request and usage CSV export. Writes are kept off the request hot path.
|
||||
Exposes a standard `OpenAI Chat Completions` API (`/v1/chat/completions` +
|
||||
`/v1/models`) to your internal network, translating between multiple upstream
|
||||
protocols via **Lua adapters**. Supports one-shot calls, SSE streaming,
|
||||
**AUTO model routing**, multimodal passthrough, and tool calls.
|
||||
|
||||
Extracted and independently evolved from the multi-source LLM adapter layer of
|
||||
[HomeAgent](https://gitcode.com/JianFeeeee/HomeAgent)
|
||||
(`internal/agent/api/provider.go` + `internal/lua/adapters/*`).
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-source**: any number of upstream sources in one process, routed by the
|
||||
request's `model`.
|
||||
- **AUTO mode**: with `default_model: AUTO`, scheduling follows the tiered AUTO
|
||||
chain saved on the Priority page (see below).
|
||||
- **Unified output**: every source speaks OpenAI format (incl.
|
||||
`reasoning_content`, `tool_calls`, `usage`).
|
||||
- **Image generation**: `POST /v1/images/generations`, routed to models with
|
||||
`kind: image`.
|
||||
- **Multimodal**: `content` arrays (`image_url` etc.) pass through losslessly;
|
||||
Anthropic/Gemini/Ollama are translated automatically.
|
||||
- **LuaJIT VM**: golua-binding LuaJIT; each adapter has its own VM + worker
|
||||
pool for safe concurrency. Pools are **elastic**: the sum of `max_concurrent`
|
||||
over an adapter's sources is a ceiling, not a preallocation — states are booted
|
||||
on demand and reclaimed when demand drops, so an idle gateway holds close to
|
||||
zero Lua states. The grow step follows the adapter's max concurrency
|
||||
(`clamp(ceil(max/8), 1, 8)`, and never exceeds the number of queued callers);
|
||||
the shrink step follows the live connection count
|
||||
(`clamp(ceil(slack/(1+in_use)), 1, slack)`), so an idle pool collapses in one
|
||||
round while a busy one gives up a single state at a time.
|
||||
- **On-demand request logs**: dashboard totals are computed from the **full**
|
||||
audit history (streamed once at startup and released — 29 MB / 221k lines in
|
||||
~260 ms), while the raw records are never held in memory: the dashboard loads
|
||||
one screen, scrolling pages the rest straight off disk, CSV export streams in
|
||||
O(1) memory, and leaving the page releases everything.
|
||||
- **Self-healing cooldown**: cooldown is capped at 5 minutes and, past the
|
||||
window's midpoint, exactly one probe request is allowed through; a recovered
|
||||
upstream (or a reset quota) returns to full rotation on that probe instead of
|
||||
waiting out the window.
|
||||
- **disable_thinking**: `disable_thinking: true` toggles reasoning per-request.
|
||||
- **Lua adapter protocol**: each source mounts a `.lua` adapter with
|
||||
`transform_request` / `transform_response` / `transform_stream_chunk` — all
|
||||
protocol differences live in Lua, Go only schedules and proxies.
|
||||
- **Signature / header hooks**: adapters may define `build_headers(meta)` to
|
||||
inject/sign request headers before the HTTP call (e.g. KimiCode style
|
||||
app-validation), with helpers like `hmac_sha256_hex`, `sha256_hex`,
|
||||
`base64_encode`.
|
||||
- **WebUI**: built-in management page to view/add/edit sources & models, edit
|
||||
the AUTO chain, and manage per-key model scopes, persisted to a runtime file.
|
||||
- **Encrypted secrets at rest**: upstream `api_key`, custom header values and
|
||||
gateway keys are stored AES-256-GCM encrypted (`master.key` 0600 next to the
|
||||
runtime file, or `LLMS_PROXY_MASTER_KEY`).
|
||||
- **Live source probing**: the status page probes each source
|
||||
(`GET {base}/models`, falling back to a minimal chat call) without touching
|
||||
the scheduler's backoff state; errors are shown on hover.
|
||||
- **Auth**: the gateway validates client keys via `gateway_keys`; independent
|
||||
from each upstream's own key.
|
||||
- **Streaming**: SSE `chat.completion.chunk` with a role-first chunk and
|
||||
`[DONE]` terminator.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# LuaJIT development libraries are required; -tags luajit is mandatory
|
||||
GOMODCACHE=... GOPROXY=off go build -tags luajit -o modelrouter ./cmd/modelrouter
|
||||
|
||||
# First run generates a default config and prints a random admin key
|
||||
./modelrouter -config config.yaml
|
||||
GOMODCACHE=... GOPROXY=off go build -tags luajit -o llmsproxy ./cmd/llmsproxy
|
||||
./llmsproxy -config config.yaml # first run generates a default config and prints a random admin key
|
||||
```
|
||||
|
||||
> Depends on [golua](https://github.com/aarzilli/golua) (LuaJIT bindings). Without
|
||||
> `-tags luajit` the build falls back to gopher-lua, which behaves slightly
|
||||
> differently.
|
||||
> Depends on [golua](https://github.com/aarzilli/golua) (LuaJIT bindings).
|
||||
> You **must** build with `-tags luajit`; otherwise the built-in superset
|
||||
> gopher-lua path is used (behavior differs slightly).
|
||||
|
||||
> **No config in the repo**: the repository ships no config file (they contain
|
||||
> keys). The generated default binds `127.0.0.1:8080` only; rotate the admin key in
|
||||
> the WebUI after the first login.
|
||||
> **No config is shipped in the repo** (config files carry real keys). On
|
||||
> first run the binary generates a default config at the `-config` path: a
|
||||
> random admin key (printed to the startup log), loopback-only `127.0.0.1:8080`,
|
||||
> and a keyless source ready to chat. Rotate the admin key in the
|
||||
> WebUI after first login.
|
||||
|
||||
```bash
|
||||
# No key -> 401
|
||||
# no key -> 401
|
||||
curl http://127.0.0.1:8080/v1/models
|
||||
|
||||
# Non-streaming ($KEY = the admin key printed at first start)
|
||||
# one-shot ($KEY = the admin key printed at first startup)
|
||||
curl -H "Authorization: Bearer $KEY" \
|
||||
-d '{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}' \
|
||||
http://127.0.0.1:8080/v1/chat/completions
|
||||
|
||||
# Streaming
|
||||
# streaming
|
||||
curl -N -H "Authorization: Bearer $KEY" \
|
||||
-d '{"model":"AUTO","stream":true,"messages":[{"role":"user","content":"hi"}]}' \
|
||||
http://127.0.0.1:8080/v1/chat/completions
|
||||
```
|
||||
|
||||
Any OpenAI SDK works: point `base_url` at the gateway and use an authorized gateway
|
||||
key as `api_key`.
|
||||
Any OpenAI SDK works: point `base_url` at the gateway, use any authorized
|
||||
gateway key as the API key.
|
||||
|
||||
## Documentation
|
||||
## Configuration
|
||||
|
||||
The detailed documentation is currently written in Chinese.
|
||||
Generated on first run at the `-config` path (default `./config.yaml`). Core fields:
|
||||
|
||||
| Topic | Document |
|
||||
|---|---|
|
||||
| Implementation and the full feature list | [docs/features.md](docs/features.md) |
|
||||
| Configuration: `config.yaml`, model discovery, multiple keys | [docs/configuration.md](docs/configuration.md) |
|
||||
| Routing: model routing, AUTO chain, cooldown, sources vs adapters, source templates | [docs/routing.md](docs/routing.md) |
|
||||
| Adapters: Lua protocol and built-in adapters | [docs/adapters.md](docs/adapters.md) · [docs/lua-adapters-en.md](docs/lua-adapters-en.md) (English) |
|
||||
| Plugins: SDK contract, hooks, UI extensions, HTTP endpoints | [docs/plugins.md](docs/plugins.md) |
|
||||
| Desktop app: Headless / Desktop artifacts, build and run | [docs/gui.md](docs/gui.md) |
|
||||
| Performance: measured memory use, tuning, thinking budget | [docs/performance.md](docs/performance.md) |
|
||||
| WebUI usage | [docs/webui.md](docs/webui.md) |
|
||||
| Development: layout, tests, branch and release discipline | [docs/development.md](docs/development.md) · [docs/git-workflow-en.md](docs/git-workflow-en.md) (English) |
|
||||
| Deployment and migration | [docs/deployment.md](docs/deployment.md) |
|
||||
```yaml
|
||||
listen: 127.0.0.1:8080 # bind address (keep internal/loopback)
|
||||
gateway_keys: [sk-gw-<random>] # initial admin key seed (random per install), migrated to the store on first start
|
||||
default_model: AUTO # when model is unroutable, follow the AUTO chain
|
||||
adapter_dir: adapters # Lua adapter dir; created+seeded if missing, read-only otherwise
|
||||
runtime_file: runtime.json # WebUI-edited sources/keys/AUTO chain persist here
|
||||
|
||||
## Renamed from llmsproxy
|
||||
|
||||
The product name has been ModelRouter for a while; the binary, config directory,
|
||||
service name and environment variables are now `modelrouter` as well:
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| `llmsproxy` (binary) | `modelrouter` |
|
||||
| `/etc/llmsproxy/` | `/etc/modelrouter/` |
|
||||
| `llmsproxy.service` | `modelrouter.service` |
|
||||
| `LLMSPROXY_*` | `MODELROUTER_*` |
|
||||
|
||||
Existing installs migrate with a dry-run-by-default script (copies, never moves,
|
||||
and can roll back):
|
||||
|
||||
```bash
|
||||
sudo ./packaging/migrate-to-modelrouter.sh # preview
|
||||
sudo ./packaging/migrate-to-modelrouter.sh --apply # apply
|
||||
sudo ./packaging/migrate-to-modelrouter.sh --rollback --apply
|
||||
sources:
|
||||
- name: deepseek
|
||||
base_url: https://api.deepseek.com
|
||||
api_key: sk-... # or api_key_env: SOME_ENV to read from an env var
|
||||
adapter: deepseek
|
||||
max_concurrent: 8
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
priority: 100 # YAML sources seed the AUTO chain on first start
|
||||
kind: chat
|
||||
- id: deepseek-v4-pro
|
||||
priority: 60
|
||||
kind: chat
|
||||
# static headers (take precedence over adapter defaults)
|
||||
headers: { X-Tenant: prod }
|
||||
# passthrough metadata for the Lua build_headers hook
|
||||
meta: { app_id: x, app_secret: y }
|
||||
temperature: 0.7
|
||||
max_tokens: 4096
|
||||
timeout: 120s # request timeout, default 120s
|
||||
```
|
||||
|
||||
Old paths and old environment variable names are still accepted on the read side,
|
||||
so an upgrade cannot fail merely because of ordering. `deploy.sh` refuses to run
|
||||
when it finds the old `/etc/llmsproxy` without a new config, because that would
|
||||
deploy a gateway with no key and no sources.
|
||||
Sensitive fields in the runtime file (`runtime_file`) are encrypted at rest:
|
||||
|
||||
The plugin SDK follows the same naming: the only public entry point is
|
||||
`modelrouter` (`llmsproxy` remains a compatibility alias). See
|
||||
[docs/plugins.md](docs/plugins.md).
|
||||
- AES-256-GCM, stored as `enc:v1:<base64>`.
|
||||
- Master key source: env var `LLMS_PROXY_MASTER_KEY` (64 hex chars), else a
|
||||
`master.key` file next to the runtime file; if neither exists it is generated
|
||||
on first start (mode 0600).
|
||||
- Old plaintext files still load; the first UI save migrates the whole file to
|
||||
ciphertext.
|
||||
- Back up `master.key` with your config — losing it makes the secrets
|
||||
undecryptable. Do not commit it.
|
||||
|
||||
### Gateway keys (multi-key)
|
||||
|
||||
Gateway auth uses a "multi-key + role + model scope" model. Keys are persisted
|
||||
under the `keys` field of the runtime file (encrypted at rest):
|
||||
|
||||
- The `gateway_keys` config is only an **initial admin key seed** — it is
|
||||
migrated into the runtime store on first start and no longer drives auth.
|
||||
- **Important: after first start, replace the admin key via the WebUI Keys
|
||||
page.** The seed key is written in plaintext in `config.yaml`, so keeping it
|
||||
active is a security risk; create a new admin key, log in with it, then
|
||||
delete the seed key.
|
||||
- The WebUI **Keys page** creates/deletes keys. Each key has a role (`admin`
|
||||
manages everything, `user` sees only its own key) and an optional **model
|
||||
scope** (model + source + token quota + reset period).
|
||||
- Clients authenticate with any authorized key's plaintext as
|
||||
`Authorization: Bearer <key>`.
|
||||
- Deleting a key removes it from the store immediately.
|
||||
|
||||
### Model routing
|
||||
|
||||
`/v1/chat/completions` `model` resolution order:
|
||||
|
||||
1. `source/model` or `source:model` prefix → pinned source;
|
||||
2. exact match of a source's `model`;
|
||||
3. with `default_model: AUTO` → follow the AUTO chain from the Priority page
|
||||
(see below);
|
||||
4. otherwise the request errors.
|
||||
|
||||
Any OpenAI client can pin to a source by setting `model` to its
|
||||
`name/anything`; `AUTO` (or the gateway's `default_model: AUTO`) follows the
|
||||
tiered AUTO chain.
|
||||
|
||||
### AUTO chain (Priority page)
|
||||
|
||||
AUTO scheduling is driven **only** by the rules saved on the Priority page. The
|
||||
numeric `models[].priority` in source configs no longer participates in
|
||||
scheduling and is no longer shown. **Chat and image generation are two
|
||||
independent chains**, switched in the Priority page via the `Chat / Image` toggle:
|
||||
|
||||
- **Chat chain** (`auto` field): serves `POST /v1/chat/completions` with
|
||||
`model: AUTO`; only `kind: chat` models are honored (image slots are skipped).
|
||||
- **Image chain** (`auto_image` field): serves `POST /v1/images/generations` with
|
||||
`model: AUTO`; only `kind: image` models are honored. With no image chain
|
||||
configured, AUTO image generation falls back to legacy discovery (all sources
|
||||
exposing an image model, in registry order) for backward compatibility.
|
||||
|
||||
Each rule is one "slot": `{ model, source, tier, token_quota, period, hours }`.
|
||||
|
||||
- `tier` is the priority tier: models in the same tier sit side by side and
|
||||
share it; tiers run high → low.
|
||||
- The same model may appear in several slots (e.g. A low → B low → A high →
|
||||
B high) and is tried in tier order.
|
||||
- `token_quota` > 0 means the slot is skipped once its tokens within the reset
|
||||
window are exhausted; `period` supports `hour` / `week` / `month` / `nhour`
|
||||
(with `hours`); empty = unlimited.
|
||||
- Within a tier, slots run by preference score; cooling / quota-exhausted /
|
||||
hard-failed slots fall through to the next tier, and total failure returns
|
||||
503 with a per-tier summary.
|
||||
|
||||
### Sources vs. adapters
|
||||
|
||||
- A **source** describes a connection to an upstream: `name`, `base_url`,
|
||||
`api_key`, model list and priorities.
|
||||
- An **adapter** is the protocol translation logic (Lua script): converts the
|
||||
unified OpenAI request to the upstream's native format and back.
|
||||
- One adapter serves many sources (e.g. `openai.lua` for any OpenAI-compatible
|
||||
site); one source can switch adapters via its `adapter` field.
|
||||
- Sources decide *whom to talk to and which models to expose*; adapters decide
|
||||
*how to talk*. They are linked by the `adapter` field inside each `sources[]`
|
||||
entry.
|
||||
|
||||
Loading flow (assembled by `Core` in `internal/core`):
|
||||
|
||||
1. On startup `lua.NewVM(adapter_dir)` loads all adapters: built-in ones
|
||||
(embedded at compile time) + same-name override files under `adapter_dir`.
|
||||
2. `config.Load` reads `config.yaml`; `config.NewStore(runtime_file)` reads
|
||||
WebUI-edited runtime sources; both are merged by name.
|
||||
3. `rebuildRegistry` creates a `provider.Provider` per source (holding its
|
||||
adapter), sizing the adapter's worker pool from the source's concurrency
|
||||
limit.
|
||||
4. Incoming requests are routed by `Registry` to a provider, which calls the
|
||||
adapter's `transform_request` → HTTP call → `transform_response` /
|
||||
`transform_stream_chunk`.
|
||||
|
||||
"Add/Edit source", "Upload adapter", "AUTO chain edits" and "per-key model
|
||||
scopes" in the WebUI take effect immediately (written to the runtime file /
|
||||
`adapter_dir`, then reassembled — no restart). Directly editing `config.yaml`
|
||||
or files under `adapter_dir` requires a process restart.
|
||||
|
||||
Deleting a source/adapter in the WebUI truly removes runtime (UI-created)
|
||||
sources and `.lua` files; base sources defined in `config.yaml` cannot rewrite
|
||||
that file, so they are hidden via a deletion tombstone (still hidden after
|
||||
restart) and can be restored by re-adding the same name in the UI.
|
||||
|
||||
### Source templates (multi-key balancing)
|
||||
|
||||
When several upstream keys share one config (URL / adapter / model list /
|
||||
concurrency / rpm), copying the whole source block N times is wasteful. A
|
||||
**source template** stores every source field except `name` and `api_key`, so you
|
||||
create N key-only-different sources from one shared skeleton — natural multi-key
|
||||
load balancing (each expanded source schedules, cools down, and reports health
|
||||
independently).
|
||||
|
||||
- WebUI Sources page, top-right **Templates** button: list all templates with
|
||||
edit / delete / create.
|
||||
- Add-source dialog has two header buttons:
|
||||
- **From template** → pick a template → form auto-fills (name + key still
|
||||
yours to enter);
|
||||
- **As template** → save the current form's non-key/name fields as a template.
|
||||
- Templates persist in the runtime file's `source_templates` field alongside
|
||||
runtime sources (hot-managed, apply immediately). Templates hold no secret
|
||||
keys, so they're safe to share / version-control.
|
||||
|
||||
A template is just a recipe — it doesn't become a source by itself. Only the
|
||||
key-bearing sources created from it carry real traffic.
|
||||
|
||||
### Memory footprint (measured and tuned)
|
||||
|
||||
Memory is not a constant: it scales with the **number of configured sources**
|
||||
(each source owns an `http.Transport` with its connection pool plus per-model
|
||||
health state), not with uptime. Measured on this machine (Linux x86_64, 12 cores):
|
||||
|
||||
| Deployment shape | Startup RSS | Settled RSS |
|
||||
|---|---|---|
|
||||
| 1 source / 1 adapter (minimal) | ~4 MB | ~10 MB |
|
||||
| 1 source + a 29 MB audit history | ~19 MB | ~20 MB |
|
||||
| **16 sources / 13 adapters / 59 models (this host)** | ~28 MB | **~32-35 MB** |
|
||||
|
||||
> For reference, the same production config used **~105 MB** before this round of
|
||||
> work. The reduction comes from three places: raw audit records are no longer kept
|
||||
> resident (~25 MB — the aggregates still scan the full history, but the scan
|
||||
> releases as it goes), Lua state pools no longer grow monotonically, and the two
|
||||
> runtime knobs below.
|
||||
|
||||
Breakdown of the production instance (per-region, from `/proc/<pid>/smaps`):
|
||||
|
||||
| Region | RSS | Notes |
|
||||
|---|---|---|
|
||||
| Go heap | ~14 MB | provider/registry/scheduler state + connection-pool buffers |
|
||||
| Other anonymous (thread stacks / LuaJIT chunks / runtime) | ~12 MB | scales with thread count and loaded adapters |
|
||||
| Binary text+rodata | ~8 MB | mapped executable pages (read-only, reclaimable by the kernel) |
|
||||
| Go runtime reservations | ~4 MB | the 2 GB+ you see in VSZ is address space, not physical memory |
|
||||
| Shared libraries | ~3 MB | libc / libluajit / libm |
|
||||
|
||||
**Two recommended deployment knobs** (environment only, no code change):
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/llmsproxy.service
|
||||
Environment=GOGC=50
|
||||
Environment=MALLOC_ARENA_MAX=2
|
||||
```
|
||||
|
||||
- `MALLOC_ARENA_MAX=2`: LuaJIT allocates through cgo into glibc malloc, and glibc
|
||||
allows up to `8 x nproc` per-thread arenas. Every OS thread that touches malloc
|
||||
claims one (~1 MB each) and **never returns it to the OS**. Measured: 8-15
|
||||
arenas (7-12 MB) down to **0**.
|
||||
- `GOGC=50`: halves the Go heap growth target. **It does nothing on its own** —
|
||||
the heap it saves is immediately consumed by additional glibc arenas (measured
|
||||
20.3 -> 21.5 MB, i.e. slightly worse) — so it must be paired with
|
||||
`MALLOC_ARENA_MAX`. Together they cut settled RSS by **~19%**. The gateway is
|
||||
I/O bound (1min10s of CPU per 9 hours here), so the extra GC cycles are free.
|
||||
|
||||
> The desktop build (Electron) already injects both when spawning the embedded
|
||||
> core (`cmd/gui/main.js`); exporting either variable yourself overrides it. If you
|
||||
> write your own systemd unit or container spec, add them there.
|
||||
|
||||
Optional: `GOMEMLIMIT=48MiB` as a soft ceiling saves roughly 1 MB more, at the
|
||||
cost of GC turning aggressive as the limit approaches. Not worth it for a relaxed
|
||||
deployment.
|
||||
|
||||
### disable_thinking
|
||||
|
||||
With `"disable_thinking": true` in the request body, the gateway passes it to
|
||||
each adapter; the DeepSeek adapter maps it to `extra_body.thinking.type =
|
||||
"disabled"`, other sources follow their own protocol.
|
||||
|
||||
### WebUI
|
||||
|
||||
Built-in admin page at `GET /`; after login you can:
|
||||
|
||||
- **Status**: source online state (live probe, error on hover), per
|
||||
model/source/key usage stats, request records filtered by key, and CSV export
|
||||
over a time range; click a model to pin the connection snippet to it.
|
||||
- **Chat**: streaming / non-streaming debug.
|
||||
- **Keys**: create/edit gateway keys, set per-key model scopes (model + source +
|
||||
token quota + reset period); admins manage all keys, users see only their own.
|
||||
- **Priority**: drag blocks to build the tiered AUTO chain.
|
||||
- **Sources**: add/edit/delete upstream sources online (secrets stored
|
||||
encrypted).
|
||||
- **Adapters**: upload / delete Lua adapter scripts.
|
||||
|
||||
Changes persist to `runtime_file` (survive restarts).
|
||||
|
||||
## Lua adapter protocol
|
||||
|
||||
Full API: **[Lua adapter docs](docs/lua-adapters.md)**
|
||||
([中文](docs/lua-adapters-en.md)).
|
||||
|
||||
Each adapter is a Lua script returning a table
|
||||
(`internal/lua/adapters/<name>.lua`), overridable by a same-name file under
|
||||
`adapter_dir` — **no recompile needed**, restart to take effect; uploads via
|
||||
the WebUI take effect immediately.
|
||||
|
||||
```lua
|
||||
return {
|
||||
name = "mysrc",
|
||||
version = "1.0.0",
|
||||
endpoint = "/chat/completions", -- upstream path (source.endpoint overrides)
|
||||
headers = { ["X-Static"] = "v" }, -- static default headers (fallback)
|
||||
|
||||
-- request: convert unified OpenAI req -> upstream native format, return string
|
||||
transform_request = function(raw_json) ... end,
|
||||
|
||||
-- response: convert upstream response to unified format string
|
||||
-- { content, reasoning_content, finish_reason, token_usage{...}, tool_calls[{...}] }
|
||||
transform_response = function(raw_json) ... end,
|
||||
|
||||
-- streaming chunk: convert upstream SSE data to { content, done, ... }; "" skips
|
||||
transform_stream_chunk = function(raw_chunk) ... end,
|
||||
|
||||
-- [optional] dynamic headers / signature hook
|
||||
-- meta = { url, method, body, api_key, timestamp, source={ name, meta={...} } }
|
||||
build_headers = function(meta) return { ["X-App-Sign"] = sign } end,
|
||||
}
|
||||
```
|
||||
|
||||
Shared helpers: `hmac_sha256_hex(key, data)`, `sha256_hex(data)`,
|
||||
`base64_encode(s)`, `tohex(s)`, `json.encode/decode`, `log(level, msg)`.
|
||||
|
||||
### Built-in adapters
|
||||
|
||||
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama`
|
||||
`kimicode`.
|
||||
|
||||
`anthropic`/`gemini`/`ollama` include multimodal conversion
|
||||
(`image_url` → their native format); with `disable_thinking` the `deepseek`
|
||||
adapter sets `extra_body.thinking.type` to `disabled`.
|
||||
|
||||
**kimicode** demonstrates `build_headers`: the cloud validates the calling
|
||||
app, so you HMAC-sign timestamp+URL+body with `meta.app_secret` and add
|
||||
`X-App-Sign`-style headers. Configure `sources[].meta.{app_id, app_secret,
|
||||
app_agent}`.
|
||||
|
||||
## Desktop GUI (Electron)
|
||||
|
||||
Optional standalone product for desktop users on your internal network: an
|
||||
**embedded ModelRouter core** (Clash Verge-style): one-click start/stop, system
|
||||
tray, autostart, silent launch, and a pixel-for-pixel embedded full WebUI
|
||||
(Status/Chat/Keys/Priority/Sources/Adapters — no login needed). Server users
|
||||
keep using the plain Go binary.
|
||||
|
||||
### Two installer kinds: Headless and Desktop
|
||||
|
||||
Release installers ship in two flavors for different audiences:
|
||||
|
||||
- **Headless (server)**: the plain core binary (no Electron), config-driven,
|
||||
for servers / containers / systemd / unattended runs. Ships as `deb` + `rpm`
|
||||
(systemd unit with memory tuning + example config) and `tar.gz` (binary +
|
||||
`config.example.yaml` + `adapters/` + README), Windows as `.zip`.
|
||||
- **Desktop (desktop app)**: the Electron GUI installer with an embedded core,
|
||||
for personal daily desktop use.
|
||||
|
||||
### GUI vs. plain backend
|
||||
|
||||
| Scenario | Pick | Why |
|
||||
| ---- | ---- | ---- |
|
||||
| Server / intranet gateway / unattended long-running | **Headless** (single binary) | ~8 MB binary; RSS scales with the number of sources (~10 MB for one, ~32-35 MB for 16 — see [Memory footprint](#memory-footprint-measured-and-tuned)), zero-dep single process — drop it into systemd or any container, remote admin |
|
||||
| Personal desktop daily use / multi-device intranet sharing | **Desktop** (GUI) | no-login embedded WebUI, tray one-click, autostart, silent background — for non-CLI users |
|
||||
| Windows desktop | **Desktop** | plain backend needs manual service registration; GUI ships native tray/autostart |
|
||||
| CI one-shot 3-platform installers | **Desktop packaging scripts** | `make gui-*` emits deb / AppImage / NSIS, drops straight into a release pipeline |
|
||||
|
||||
Both share the exact same `llmsproxy` core (LuaJIT build) — configs and
|
||||
adapters are fully compatible, freely interchangeable.
|
||||
|
||||
### Build & run
|
||||
|
||||
```bash
|
||||
cd cmd/gui
|
||||
npm install
|
||||
make gui # or npm run dev — dev run (needs make build first for bin/llmsproxy)
|
||||
make gui-deb # local deb (share with other Linux users)
|
||||
make gui-dist # deb + rpm + AppImage (linux; rpm needs system rpmbuild)
|
||||
make core-dist # core deb + rpm + tar.gz (server edition; needs nfpm)
|
||||
make gui-win # Windows NSIS (host mingw + wine)
|
||||
make gui-win-docker # Windows NSIS, fully dockerized (wine in image; no host mingw/node/wine)
|
||||
```
|
||||
|
||||
Artifacts land in `cmd/build/gui-dist/`: `ModelRouter-<ver>.AppImage`,
|
||||
`modelrouter-gui_<ver>_amd64.deb`, `ModelRouter Setup <ver>.exe` (win); core
|
||||
packages land in `cmd/build/dist/`: `llmsproxy_<ver>_amd64.deb`,
|
||||
`llmsproxy-<ver>.x86_64.rpm`. Both target families run
|
||||
`packaging/verify-dist.sh` afterwards — a size floor that rejects degenerate
|
||||
installers (1.3.0 shipped a 264 KB NSIS stub because the host wine was broken
|
||||
and nothing caught it). At release time artifacts are renamed into the
|
||||
`ModelRouter-Desktop-*` (GUI) and `ModelRouter-Headless-*` (plain backend)
|
||||
families before upload to GitCode Releases.
|
||||
|
||||
#### Windows packaging (dockerized, recommended)
|
||||
|
||||
`make gui-win-docker` does cross-compile + NSIS **entirely in docker**; the host
|
||||
needs **no mingw, no wine, no node**. Wine (with the i386 runtime) lives inside
|
||||
the image, fixing the broken-host-wine failure that produced the 264 KB stub
|
||||
installer.
|
||||
|
||||
- Image: `cmd/gui/docker/win-builder/Dockerfile` (`golang:1.25` + mingw-w64 +
|
||||
node + wine32/wine64 + prebuilt LuaJIT for Windows: `lua51.dll` + import lib).
|
||||
LuaJIT source comes from the gitcode mirror (reachable in CN) with github
|
||||
fallback; Go modules go through `https://goproxy.cn`.
|
||||
- One-shot release: `cmd/gui/scripts/dist-win-docker.sh` — core build + NSIS
|
||||
packaging run inside the container (`USE_SYSTEM_WINE=true` uses the image's
|
||||
wine); electron-builder's ~400 MB toolchain cache lives in a docker volume so
|
||||
later builds reuse it.
|
||||
- The `modelrouter/win-builder` image is auto-built on first run (~5-10 min)
|
||||
and reused afterwards.
|
||||
- After packaging, `packaging/verify-dist.sh` runs as a gate: an exe under
|
||||
5 MB (or a deb/rpm/7z under 10 MB) fails the build — a broken installer can
|
||||
never reach a Release again.
|
||||
|
||||
```bash
|
||||
# Full Windows release (rebuild core + NSIS):
|
||||
make gui-win-docker
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Embedded core**: auto-spawns the bundled `llmsproxy` (LuaJIT build);
|
||||
config/keys/adapters live in `<userData>/profile/`. First run generates a
|
||||
random admin key written into `keys` (not seed) and self-injects it — WebUI
|
||||
needs no login and never nags about changing the initial key. Port changeable
|
||||
in settings (default 8787).
|
||||
- **System tray**: core status dot, autostart/silent toggles, start/stop/restart
|
||||
core, quit.
|
||||
- **Silent launch**: `--silent` flag or setting — starts to the tray only;
|
||||
first run always shows the window.
|
||||
- **Autostart**: `setLoginItemSettings` on Windows/macOS; writes
|
||||
`~/.config/autostart/modelrouter-gui.desktop` on Linux, appending `--silent`
|
||||
when silent mode is on.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
cmd/modelrouter # entry point (pure Go server)
|
||||
cmd/gui # Electron desktop shell (embeds the same core)
|
||||
internal/config # config parsing, sealed storage, runtime file
|
||||
internal/core # scheduling, kernel actions, state and audit
|
||||
internal/lua # LuaJIT VM + elastic worker pool + built-in adapters (embed)
|
||||
internal/lua/plugins # optional plugin examples (not shipped in binary/archives)
|
||||
```
|
||||
cmd/llmsproxy # entry point
|
||||
internal/config # YAML config loading/validation
|
||||
internal/lua # LuaJIT VM + worker pool + AdapterCache + built-ins (embed)
|
||||
internal/provider # Provider(HTTP) + Registry(routing)
|
||||
internal/gateway # OpenAI-compatible HTTP service + auth + streaming + WebUI
|
||||
internal/types # unified format and OpenAI wire types
|
||||
packaging/ # deb/rpm/tar packaging, systemd unit, migration script
|
||||
docs/ # topic documentation
|
||||
internal/gateway # OpenAI-compatible HTTP + auth + SDK/streaming + WebUI
|
||||
internal/types # unified format & OpenAI wire types
|
||||
```
|
||||
|
||||
## Development and tests
|
||||
## Development & collaboration
|
||||
|
||||
- **Git branching workflow**: main / feature / release-branch conventions in
|
||||
**[docs/git-workflow-en.md](docs/git-workflow-en.md)**
|
||||
([中文](docs/git-workflow.md)). The line: `main` is always deployable →
|
||||
features merge into `main` → cut `release/vX.Y.Z` and tag → release → hotfixes
|
||||
land on the release branch and are cherry-picked back into `main`.
|
||||
- **Lua adapter protocol**: see
|
||||
**[docs/lua-adapters-en.md](docs/lua-adapters-en.md)**
|
||||
([中文](docs/lua-adapters.md)).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
go test -tags luajit ./... # everything
|
||||
go test -tags luajit ./internal/lua # one package (required for LuaJIT changes)
|
||||
go test -tags luajit ./...
|
||||
```
|
||||
|
||||
- Branch and release discipline: [docs/git-workflow-en.md](docs/git-workflow-en.md).
|
||||
- Plugins live in `plugin_dir`. The gateway never creates, overwrites or deletes
|
||||
plugin files, and a desktop upgrade never touches them either.
|
||||
|
||||
## License
|
||||
|
||||
MIT License, see [LICENSE](./LICENSE).
|
||||
Covers: config validation, adapter load/transform, signature hooks, gateway
|
||||
auth, SDK round trip, SSE streaming, model routing, multimodal passthrough and
|
||||
disable_thinking.
|
||||
|
||||
276
cmd/gui/main.js
276
cmd/gui/main.js
@ -1,5 +1,5 @@
|
||||
// ModelRouter Desktop GUI — main process
|
||||
// Desktop shell: bundles and manages an embedded modelrouter core instance
|
||||
// Clash-Verge-style: bundles and manages an embedded llmsproxy core instance
|
||||
// in a per-user profile dir (userData), auto-starts it, keeps a tray icon,
|
||||
// supports launch-at-login. Server-side users keep using the pure Go binary.
|
||||
const {
|
||||
@ -17,8 +17,7 @@ const fs = require("fs");
|
||||
const http = require("http");
|
||||
const os = require("os");
|
||||
const crypto = require("crypto");
|
||||
const { spawn, execFileSync } = require("child_process");
|
||||
const pluginSync = require("./plugin-sync");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
|
||||
function log(msg) {
|
||||
@ -41,36 +40,13 @@ console.error = function () {
|
||||
// Per-user profile dir: <userData>/profile
|
||||
const PROFILE_DIR = path.join(app.getPath("userData"), "profile");
|
||||
const CONFIG_FILE = path.join(PROFILE_DIR, "config.yaml");
|
||||
// Plugin tree for the embedded core. The core deliberately does NOT default
|
||||
// plugin_dir (an operator opts in), so the desktop build must set it — without
|
||||
// it the whole plugin system stays off and the channels plugin (multi-platform
|
||||
// account login + credential vault + auto check-in) is invisible in the WebUI.
|
||||
// Seeding is the core's job (SeedBundled fills MISSING files only), so a user's
|
||||
// edited copy always wins.
|
||||
const PLUGINS_DIR = path.join(PROFILE_DIR, "plugins");
|
||||
// Packaged: extraResource at process.resourcesPath/bin (outside asar, spawnable).
|
||||
// Dev: cmd/gui/bin, populated by prepare-core.js.
|
||||
// The embedded core is named after the product. Installs produced before the
|
||||
// llmsproxy -> modelrouter rename still carry the old file name inside their
|
||||
// profile, so resolution falls back to it instead of declaring the core missing.
|
||||
const CORE_BIN_DIR = path.join(
|
||||
const CORE_EXE = path.join(
|
||||
app.isPackaged ? process.resourcesPath : __dirname,
|
||||
"bin",
|
||||
);
|
||||
const CORE_EXE = path.join(
|
||||
CORE_BIN_DIR,
|
||||
process.platform === "win32" ? "modelrouter.exe" : "modelrouter",
|
||||
);
|
||||
const LEGACY_CORE_EXE = path.join(
|
||||
CORE_BIN_DIR,
|
||||
process.platform === "win32" ? "llmsproxy.exe" : "llmsproxy",
|
||||
);
|
||||
|
||||
function resolveCoreExe() {
|
||||
if (fs.existsSync(CORE_EXE)) return CORE_EXE;
|
||||
if (fs.existsSync(LEGACY_CORE_EXE)) return LEGACY_CORE_EXE;
|
||||
return CORE_EXE;
|
||||
}
|
||||
// Packaged window icon: must be a REAL file on disk (extraResource), not an
|
||||
// asar path — Electron/GTK pulls BrowserWindow icons from the filesystem and
|
||||
// crashes (SIGSEGV) when given an asar virtual path on Linux.
|
||||
@ -121,99 +97,24 @@ function readAdminKey() {
|
||||
// gateway_keys entries get marked seed:true by the core (-> "replace the
|
||||
// initial key" warning in the web UI), while a plain keys entry authenticates
|
||||
// without any seed warning.
|
||||
const raw = readConfigRaw();
|
||||
if (!raw) return "";
|
||||
// keys:
|
||||
// - key: sk-gw-...
|
||||
const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec(
|
||||
raw,
|
||||
);
|
||||
let v = k && k[1] ? k[1] : "";
|
||||
if (!v) {
|
||||
// legacy: gateway_keys list (pre-migration configs)
|
||||
const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(raw);
|
||||
if (g && g[1]) v = g[1];
|
||||
if (!v) {
|
||||
const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw);
|
||||
if (g2 && g2[1]) v = g2[1];
|
||||
}
|
||||
}
|
||||
// The core seals credentials at rest (enc:v1:...), so from the second start
|
||||
// onward the file holds ciphertext. Injecting that as a cookie is a
|
||||
// guaranteed 401, which surfaces to the user as "enter your key" for a key
|
||||
// they never set. Ask the core to unseal rather than reimplementing its
|
||||
// crypto here. -show-secrets only reads, prints and exits: it starts nothing
|
||||
// and writes nothing.
|
||||
if (v && v.indexOf(SEALED_PREFIX) === 0) return unsealViaCore();
|
||||
return v;
|
||||
}
|
||||
|
||||
const SEALED_PREFIX = "enc:v1:";
|
||||
|
||||
function readConfigRaw() {
|
||||
try {
|
||||
if (!fs.existsSync(CONFIG_FILE)) return "";
|
||||
return fs.readFileSync(CONFIG_FILE, "utf-8");
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the admin key in the clear, or "" if the core cannot be asked.
|
||||
//
|
||||
// cwd must be the directory that holds master.key. The core derives that path
|
||||
// from the config's runtime_file, so a call made from any other directory has
|
||||
// the core generate a *second* master key in the CWD and then fail to decrypt
|
||||
// ("master key changed?"). Electron's CWD is not the profile dir, so this is
|
||||
// not optional: without it the desktop build cannot read its own key back.
|
||||
//
|
||||
// readAdminKey runs on every outbound request, and this spawns a process, so
|
||||
// the result is cached against the config's mtime. Editing the config (or the
|
||||
// port rewrite, or the first write) changes the mtime and invalidates it, which
|
||||
// keeps the "read live so ordering never matters" property the auth rule
|
||||
// depends on.
|
||||
function unsealViaCore() {
|
||||
const st = safeStat(CONFIG_FILE);
|
||||
const stamp = st ? st.mtimeMs : 0;
|
||||
if (unsealCache && unsealCache.stamp === stamp) return unsealCache.key;
|
||||
let key = "";
|
||||
try {
|
||||
const out = execFileSync(
|
||||
resolveCoreExe(),
|
||||
["-config", CONFIG_FILE, "-show-secrets"],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
cwd: PROFILE_DIR,
|
||||
},
|
||||
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
||||
// keys:
|
||||
// - key: sk-gw-...
|
||||
const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec(
|
||||
raw,
|
||||
);
|
||||
// -show-secrets prints one line per credential:
|
||||
// key <name> role=<role> <value>
|
||||
const admin = /^key\s+\S+\s+role=admin\s+(\S+)\s*$/m.exec(out);
|
||||
if (admin && admin[1]) key = admin[1];
|
||||
// Tolerate a field-order or spacing change rather than locking the user out.
|
||||
if (!key) {
|
||||
const loose = /role=admin\s+(\S+)/.exec(out);
|
||||
if (loose && loose[1]) key = loose[1];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("unseal via core failed:", e.message);
|
||||
}
|
||||
// Only cache a success. A transient failure must not pin an empty key until
|
||||
// the config next changes, or a momentary spawn error locks the user out.
|
||||
if (key) unsealCache = { stamp, key };
|
||||
return key;
|
||||
}
|
||||
|
||||
let unsealCache = null;
|
||||
|
||||
function safeStat(p) {
|
||||
try {
|
||||
return fs.statSync(p);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (k && k[1]) return k[1];
|
||||
// legacy: gateway_keys list (pre-migration configs)
|
||||
const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(
|
||||
raw,
|
||||
);
|
||||
if (g && g[1]) return g[1];
|
||||
const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw);
|
||||
if (g2 && g2[1]) return g2[1];
|
||||
} catch (e) {}
|
||||
return "";
|
||||
}
|
||||
|
||||
const embeddedBaseUrl = () =>
|
||||
@ -231,7 +132,7 @@ function coreStarted() {
|
||||
function writeDefaultEmbeddedConfig() {
|
||||
// Generate a self-contained embedded profile with a fresh random admin key.
|
||||
// No upstream sources are preconfigured — the desktop user adds them via
|
||||
// the WebUI's sources page (empty by default, like the WebUI profile).
|
||||
// the WebUI's sources page (like Clash Verge's empty-by-default profile).
|
||||
// If a config already exists we do NOT overwrite it (keep user's edits).
|
||||
if (fs.existsSync(CONFIG_FILE)) return;
|
||||
const key = "sk-gw-" + crypto.randomBytes(16).toString("hex");
|
||||
@ -240,9 +141,6 @@ function writeDefaultEmbeddedConfig() {
|
||||
"listen: 127.0.0.1:" + (settings.port || DEFAULT_PORT),
|
||||
"default_model: AUTO",
|
||||
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
|
||||
// Plugin tree: channels/billing/quota/usage ship inside the core binary and
|
||||
// are seeded into this dir on first start. Omitting it disables plugins.
|
||||
"plugin_dir: " + JSON.stringify(PLUGINS_DIR),
|
||||
"runtime_file: " + JSON.stringify(path.join(PROFILE_DIR, "runtime.json")),
|
||||
"",
|
||||
// Admin key as a plain `keys` entry (no seed flag) so the web UI never
|
||||
@ -288,18 +186,15 @@ function startCore() {
|
||||
console.log("core already running");
|
||||
return;
|
||||
}
|
||||
const coreExe = resolveCoreExe();
|
||||
if (!fs.existsSync(coreExe)) {
|
||||
console.error("embedded core binary missing:", coreExe);
|
||||
if (!fs.existsSync(CORE_EXE)) {
|
||||
console.error("embedded core binary missing:", CORE_EXE);
|
||||
return;
|
||||
}
|
||||
// ensure profile dir + config + local adapters exist
|
||||
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
||||
if (!fs.existsSync(CONFIG_FILE)) writeDefaultEmbeddedConfig();
|
||||
pluginSync.ensurePluginDirConfigured(CONFIG_FILE, PLUGINS_DIR, log);
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
|
||||
coreProc = spawn(coreExe, ["-config", CONFIG_FILE], {
|
||||
coreProc = spawn(CORE_EXE, ["-config", CONFIG_FILE], {
|
||||
cwd: PROFILE_DIR,
|
||||
env: Object.assign({}, process.env, {
|
||||
LLMS_PROXY_GUI: "1",
|
||||
@ -652,57 +547,12 @@ function createWindow() {
|
||||
},
|
||||
});
|
||||
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
|
||||
// External links ALWAYS go to the user's default browser, never an in-app
|
||||
// window. Three paths have to be covered, and missing any one of them is why
|
||||
// "click the login link and nothing happens / the app navigates away" shows up:
|
||||
// 1. window.open / target=_blank -> setWindowOpenHandler
|
||||
// 2. top-level navigation (same-tab click) -> will-navigate
|
||||
// 3. navigation inside the WebUI <iframe> -> will-frame-navigate
|
||||
// The embedded core's own origin (and file:// for the shell) must keep
|
||||
// working, otherwise the WebUI and plugin pages stop loading.
|
||||
const isInternal = (url) =>
|
||||
!url ||
|
||||
url === "about:blank" ||
|
||||
url.startsWith("file://") ||
|
||||
url.startsWith(embeddedBaseUrl()) ||
|
||||
url.startsWith("devtools://");
|
||||
const openExternally = (url) => {
|
||||
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
|
||||
log("[gui] open in default browser: " + url);
|
||||
shell.openExternal(url);
|
||||
}
|
||||
};
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (!isInternal(url)) openExternally(url);
|
||||
if (url && url.startsWith("http")) shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
mainWindow.webContents.on("will-navigate", (e, url) => {
|
||||
if (isInternal(url)) return;
|
||||
e.preventDefault();
|
||||
openExternally(url);
|
||||
});
|
||||
// iframe navigations (the WebUI content area and the plugin pages inside it).
|
||||
// The channels plugin renders its server-side login browser (noVNC) in an
|
||||
// iframe on a LOOPBACK port, so loopback MUST stay in-app — blocking it would
|
||||
// break VNC login. Everything non-loopback is external: open the default
|
||||
// browser instead of silently replacing the gateway UI with a third-party page.
|
||||
const isLoopback = (url) => {
|
||||
try {
|
||||
const h = new URL(url).hostname;
|
||||
return h === "127.0.0.1" || h === "localhost" || h === "[::1]";
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (mainWindow.webContents.on) {
|
||||
mainWindow.webContents.on("will-frame-navigate", (e) => {
|
||||
if (isInternal(e.url) || isLoopback(e.url)) return;
|
||||
e.preventDefault();
|
||||
openExternally(e.url);
|
||||
});
|
||||
}
|
||||
// Silent start: `--silent` CLI flag or settings.silentStart keeps the app
|
||||
// in the tray without showing the window (desktop-shell behaviour). First run
|
||||
// in the tray without showing the window (Clash-Verge style). First run
|
||||
// always shows the window so the user sees the dashboard.
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
const firstRun = !fs.existsSync(
|
||||
@ -746,78 +596,6 @@ ipcMain.handle(
|
||||
(e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(),
|
||||
);
|
||||
|
||||
// ---- plugin management over IPC -------------------------------------------
|
||||
//
|
||||
// The renderer cannot call the embedded core directly: it has no key and no
|
||||
// network identity, and the core binds a loopback port that only the main
|
||||
// process knows about. So every plugin action is proxied through the main
|
||||
// process, which already knows how to obtain the admin key (unsealViaCore).
|
||||
//
|
||||
// The proxy is deliberately a raw (method, path, body) pass-through rather than
|
||||
// a fixed set of commands. A fixed set would have to be extended for every new
|
||||
// plugin endpoint, and the one thing worse than "no button for this" is "a
|
||||
// button that silently does nothing" — with a pass-through the renderer can talk
|
||||
// to any /api/plugins route the core grows, and the path is validated here so
|
||||
// this channel cannot be used to reach arbitrary endpoints.
|
||||
function pluginProxy(req) {
|
||||
const { method, path, body } = req || {};
|
||||
const M = ["GET", "POST", "PUT", "DELETE"];
|
||||
if (!M.includes(method)) throw new Error("bad method: " + method);
|
||||
// The path must stay inside the plugin namespace. A prefix check alone would
|
||||
// still allow /api/plugins/../keys, so reject any traversal outright.
|
||||
if (typeof path !== "string" || !path.startsWith("/api/plugins")) {
|
||||
throw new Error("path must start with /api/plugins");
|
||||
}
|
||||
if (path.includes("..") || path.includes("\\")) {
|
||||
throw new Error("path traversal rejected");
|
||||
}
|
||||
const key = unsealViaCore();
|
||||
if (!key) throw new Error("no admin key available yet");
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(embeddedBaseUrl() + path);
|
||||
const data = body == null ? null : JSON.stringify(body);
|
||||
const headers = { Authorization: "Bearer " + key };
|
||||
if (data) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Content-Length"] = Buffer.byteLength(data);
|
||||
}
|
||||
const r = http.request(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port: u.port,
|
||||
path: u.pathname + u.search,
|
||||
method,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
let raw = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (c) => (raw += c));
|
||||
res.on("end", () => {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = raw ? JSON.parse(raw) : null;
|
||||
} catch (e) {
|
||||
parsed = { raw };
|
||||
}
|
||||
if (res.statusCode >= 400) {
|
||||
const msg =
|
||||
(parsed && parsed.error && parsed.error.message) ||
|
||||
"HTTP " + res.statusCode;
|
||||
reject(new Error(msg));
|
||||
return;
|
||||
}
|
||||
resolve(parsed);
|
||||
});
|
||||
},
|
||||
);
|
||||
r.on("error", reject);
|
||||
if (data) r.write(data);
|
||||
r.end();
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("plugins:proxy", (_e, req) => pluginProxy(req));
|
||||
ipcMain.handle("core:state", () => ({
|
||||
running: coreStarted() && coreReady,
|
||||
ready: coreReady,
|
||||
@ -826,8 +604,8 @@ ipcMain.handle("core:state", () => ({
|
||||
port: settings.port || DEFAULT_PORT,
|
||||
profileDir: PROFILE_DIR,
|
||||
configFile: CONFIG_FILE,
|
||||
coreExe: resolveCoreExe(),
|
||||
coreExists: fs.existsSync(resolveCoreExe()),
|
||||
coreExe: CORE_EXE,
|
||||
coreExists: fs.existsSync(CORE_EXE),
|
||||
}));
|
||||
ipcMain.handle("core:start", () => {
|
||||
app.isQuitting = false;
|
||||
@ -899,7 +677,7 @@ if (gotLock) {
|
||||
createWindow();
|
||||
createTray();
|
||||
|
||||
// start embedded core if not already running (another instance of modelrouter)
|
||||
// start embedded core if not already running (another instance of llmsproxy)
|
||||
setTimeout(async () => {
|
||||
const p = await probeEmbedded();
|
||||
if (!p.ok) {
|
||||
|
||||
22
cmd/gui/package-lock.json
generated
22
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "3.0.2",
|
||||
"version": "1.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "3.0.2",
|
||||
"version": "1.5.2",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
@ -582,7 +582,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/webcrypto": {
|
||||
"version": "1.7.5",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz",
|
||||
"integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==",
|
||||
"dev": true,
|
||||
@ -1245,7 +1245,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
@ -2288,7 +2288,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent": {
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
||||
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
||||
"dev": true,
|
||||
@ -2532,7 +2532,7 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
@ -2623,7 +2623,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"dev": true,
|
||||
@ -2715,7 +2715,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
||||
"dev": true,
|
||||
@ -3335,7 +3335,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/resedit": {
|
||||
"version": "1.7.5",
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
"integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==",
|
||||
"dev": true,
|
||||
@ -3492,7 +3492,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
@ -3612,7 +3612,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
||||
"dev": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "3.0.2",
|
||||
"version": "1.5.2",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
@ -39,8 +39,8 @@
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "bin/modelrouter",
|
||||
"to": "bin/modelrouter"
|
||||
"from": "bin/llmsproxy",
|
||||
"to": "bin/llmsproxy"
|
||||
},
|
||||
{
|
||||
"from": "build/icon.png",
|
||||
@ -66,8 +66,8 @@
|
||||
"icon": "build/icon.ico",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "bin/modelrouter.exe",
|
||||
"to": "bin/modelrouter.exe"
|
||||
"from": "bin/llmsproxy.exe",
|
||||
"to": "bin/llmsproxy.exe"
|
||||
},
|
||||
{
|
||||
"from": "bin/lua51.dll",
|
||||
@ -81,7 +81,6 @@
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"plugin-sync.js",
|
||||
"preload.js",
|
||||
"renderer/**/*",
|
||||
"build/**/*",
|
||||
@ -89,5 +88,5 @@
|
||||
],
|
||||
"asar": true
|
||||
},
|
||||
"homepage": "https://atomgit.com/HeNanMU-EEA/ModelRouter"
|
||||
"homepage": "https://gitcode.com/JianFeeeee/ModelRouter"
|
||||
}
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
// Plugin-tree maintenance for the desktop profile (no Electron imports, so this
|
||||
// is unit-testable with plain node).
|
||||
//
|
||||
// The desktop may configure an operator-selected plugin directory, but it never
|
||||
// owns the files in that directory. In particular, upgrades must not seed,
|
||||
// replace, rename, or delete plugins. Plugin installation is an explicit
|
||||
// operator action (or an external package manager action).
|
||||
const fs = require("fs");
|
||||
|
||||
// ensurePluginDirConfigured back-fills `plugin_dir` into an existing config.
|
||||
// Appending a top-level YAML key is safe for the shapes this file can have
|
||||
// (flat mapping + a keys list). Returns "added" | "present" | "absent" | "error".
|
||||
function ensurePluginDirConfigured(configFile, pluginsDir, log) {
|
||||
try {
|
||||
if (!fs.existsSync(configFile)) return "absent";
|
||||
const cur = fs.readFileSync(configFile, "utf-8");
|
||||
if (/^\s*plugin_dir\s*:/m.test(cur)) return "present";
|
||||
fs.writeFileSync(
|
||||
configFile,
|
||||
cur.replace(/\s*$/, "") +
|
||||
"\n# added by ModelRouter Desktop: enables the operator plugin directory\n" +
|
||||
"plugin_dir: " + JSON.stringify(pluginsDir) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
if (log) log("[gui] plugin_dir back-filled into " + configFile);
|
||||
return "added";
|
||||
} catch (e) {
|
||||
if (log) log("[gui] ensurePluginDirConfigured failed: " + e.message);
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ensurePluginDirConfigured };
|
||||
@ -1,48 +0,0 @@
|
||||
// Unit tests for desktop plugin-directory configuration (plain node).
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { ensurePluginDirConfigured } = require("./plugin-sync");
|
||||
|
||||
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sync-"));
|
||||
|
||||
test("back-fills plugin_dir into an existing profile without it", () => {
|
||||
const dir = tmp();
|
||||
const cfg = path.join(dir, "config.yaml");
|
||||
fs.writeFileSync(cfg, "listen: 127.0.0.1:8787\ndefault_model: AUTO\nkeys:\n - key: k\n role: admin\n");
|
||||
const plugins = path.join(dir, "plugins");
|
||||
assert.equal(ensurePluginDirConfigured(cfg, plugins, null), "added");
|
||||
const out = fs.readFileSync(cfg, "utf-8");
|
||||
assert.match(out, /^plugin_dir: "/m);
|
||||
assert.match(out, /^listen: 127.0.0.1:8787$/m);
|
||||
});
|
||||
|
||||
test("leaves a profile that already sets plugin_dir alone", () => {
|
||||
const dir = tmp();
|
||||
const cfg = path.join(dir, "config.yaml");
|
||||
const orig = 'plugin_dir: "/custom/plugins"\nlisten: ":1"\n';
|
||||
fs.writeFileSync(cfg, orig);
|
||||
assert.equal(ensurePluginDirConfigured(cfg, "/whatever", null), "present");
|
||||
assert.equal(fs.readFileSync(cfg, "utf-8"), orig);
|
||||
});
|
||||
|
||||
test("missing config is not created by the back-fill", () => {
|
||||
const dir = tmp();
|
||||
const cfg = path.join(dir, "config.yaml");
|
||||
assert.equal(ensurePluginDirConfigured(cfg, path.join(dir, "p"), null), "absent");
|
||||
assert.equal(fs.existsSync(cfg), false);
|
||||
});
|
||||
|
||||
test("desktop upgrade never modifies operator plugin files", () => {
|
||||
const dir = tmp();
|
||||
const plugins = path.join(dir, "plugins");
|
||||
fs.mkdirSync(plugins, { recursive: true });
|
||||
fs.writeFileSync(path.join(plugins, "channels.lua"), "OPERATOR FILE");
|
||||
fs.writeFileSync(path.join(plugins, "my-own.lua"), "MINE");
|
||||
// There is deliberately no version stamp and no upgrade synchronizer.
|
||||
assert.equal(fs.readFileSync(path.join(plugins, "channels.lua"), "utf-8"), "OPERATOR FILE");
|
||||
assert.equal(fs.readFileSync(path.join(plugins, "my-own.lua"), "utf-8"), "MINE");
|
||||
assert.equal(fs.readdirSync(dir).includes(".plugin-seed-version"), false);
|
||||
});
|
||||
@ -16,13 +16,6 @@ contextBridge.exposeInMainWorld("modelrouter", {
|
||||
key: () => ipcRenderer.invoke("core:key"),
|
||||
onState: (cb) => ipcRenderer.on("core:state", (_e, d) => cb(d)),
|
||||
},
|
||||
plugins: {
|
||||
// Raw pass-through to the embedded core's /api/plugins surface. The main
|
||||
// process validates the path and attaches the admin key; the renderer never
|
||||
// sees either.
|
||||
request: (method, path, body) =>
|
||||
ipcRenderer.invoke("plugins:proxy", { method, path, body }),
|
||||
},
|
||||
settings: {
|
||||
get: () => ipcRenderer.invoke("settings:get"),
|
||||
set: (patch) => ipcRenderer.invoke("settings:set", patch),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// ModelRouter Desktop GUI — renderer
|
||||
// Desktop shell: the content area is a full-screen iframe loading the
|
||||
// Clash-Verge-style: the content area is a full-screen iframe loading the
|
||||
// real WebUI 1:1 (status / chat / keys / priority / sources / adapters).
|
||||
// The shell only adds the titlebar, settings overlay and tray integration.
|
||||
const state = {
|
||||
@ -155,7 +155,6 @@ async function openSettings() {
|
||||
$("#set-tray").checked = !!state.settings.minimizeToTray;
|
||||
$("#settings-overlay").style.display = "flex";
|
||||
renderRail();
|
||||
loadPlugins();
|
||||
}
|
||||
function closeSettings() {
|
||||
$("#settings-overlay").style.display = "none";
|
||||
@ -183,296 +182,6 @@ async function saveSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// esc / escAttr escape text for innerHTML. The WebUI has its own copies; the
|
||||
// shell needs its own because renderer/app.js is a separate document that
|
||||
// never loads index.html's script.
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
||||
);
|
||||
}
|
||||
function escAttr(s) {
|
||||
return esc(s).replace(/`/g, "`");
|
||||
}
|
||||
|
||||
// ===== plugin management =====
|
||||
//
|
||||
// The desktop shell manages plugins through the embedded core's /api/plugins
|
||||
// surface, proxied over IPC (see plugins:proxy in the main process). The
|
||||
// renderer never holds the admin key.
|
||||
//
|
||||
// Scope note: the desktop build has no plugin_dir configured by default, so this
|
||||
// panel normally reports "plugins disabled" with the one-line fix. That is a
|
||||
// deliberate state, not an error — the packaged profile is a per-user directory
|
||||
// and seeding a plugin tree into someone's home without asking would be rude.
|
||||
|
||||
async function loadPlugins() {
|
||||
const list = $("#pl-list");
|
||||
const hint = $("#set-plugins-hint");
|
||||
if (!list || !hint) return;
|
||||
let j;
|
||||
try {
|
||||
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
|
||||
} catch (e) {
|
||||
hint.textContent = "内核未就绪:" + e.message;
|
||||
list.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
if (!j.plugin_dir) {
|
||||
hint.innerHTML =
|
||||
'未配置 <code>plugin_dir</code>,插件功能未启用。在 config.yaml 加一行后重启内核即可。';
|
||||
list.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const rows = j.on_disk || [];
|
||||
window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
|
||||
const active = rows.filter((p) => p.loaded && !p.disabled).length;
|
||||
const broken = rows.filter((p) => !p.loaded).length;
|
||||
hint.textContent =
|
||||
`${rows.length} 个插件 · ${active} 个启用中` +
|
||||
(broken ? ` · ${broken} 个加载失败` : "");
|
||||
list.innerHTML = rows.length
|
||||
? rows
|
||||
.map((p) => {
|
||||
const cls = !p.loaded ? "pl-broken" : p.disabled ? "pl-off" : "pl-on";
|
||||
const label = !p.loaded
|
||||
? "加载失败"
|
||||
: p.disabled
|
||||
? "已禁用"
|
||||
: "启用中";
|
||||
const btn = p.loaded
|
||||
? `<button class="ghost" data-act="toggle" data-name="${escAttr(
|
||||
p.name,
|
||||
)}" data-en="${p.disabled ? "1" : "0"}">${
|
||||
p.disabled ? "启用" : "禁用"
|
||||
}</button>`
|
||||
: "";
|
||||
const builtin = p.builtin
|
||||
? '<span class="pl-builtin">内置</span>'
|
||||
: "";
|
||||
// Edit / view source / delete are the operations an operator
|
||||
// actually needs after enable/disable: a plugin that cannot be
|
||||
// read back cannot be fixed, and one that cannot be removed cannot
|
||||
// be rolled back.
|
||||
const acts = [
|
||||
btn,
|
||||
`<button class="ghost" data-act="code" data-name="${escAttr(p.name)}">${
|
||||
p.builtin ? "查看源码" : "编辑"
|
||||
}</button>`,
|
||||
builtin
|
||||
? ""
|
||||
: `<button class="ghost danger" data-act="del" data-name="${escAttr(
|
||||
p.name,
|
||||
)}">删除</button>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
const pages = Array.isArray(p.pages) ? p.pages : [];
|
||||
return `<div class="pl-item ${cls}">
|
||||
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
|
||||
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
|
||||
${pages.length ? `<div class="pl-desc">页面:${esc(pages.join(", "))}</div>` : ""}
|
||||
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
|
||||
<div class="pl-acts">${acts}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("")
|
||||
: '<div class="pl-empty">插件目录为空</div>';
|
||||
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
|
||||
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
|
||||
});
|
||||
list.querySelectorAll('button[data-act="code"]').forEach((b) => {
|
||||
b.onclick = () => editPlugin(b.dataset.name);
|
||||
});
|
||||
list.querySelectorAll('button[data-act="del"]').forEach((b) => {
|
||||
b.onclick = () => deletePlugin(b.dataset.name);
|
||||
});
|
||||
}
|
||||
|
||||
// editPlugin shows the plugin source in a modal. Built-in plugins are
|
||||
// read-only: their file ships inside the binary, so a save would either be
|
||||
// ignored or silently do nothing — showing an editable text area that
|
||||
// discards on save is worse than saying so up front.
|
||||
function editPlugin(name) {
|
||||
// The plugin list is the only place `builtin` is reported; keep the last
|
||||
// fetch so the modal does not need a second round trip (and cannot guess).
|
||||
const row = (window.__plRows || []).find((p) => p.name === name);
|
||||
const isBuiltin = !!(row && row.builtin);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "overlay";
|
||||
wrap.innerHTML = `<div class="overlay-card pl-wide">
|
||||
<div class="overlay-head"><h2>${esc(name)}${isBuiltin ? "(内置,只读)" : ""}</h2></div>
|
||||
${isBuiltin
|
||||
? `<p class="hint">内置插件随内核发布,源码在二进制内,不可编辑。</p>`
|
||||
: `<textarea id="pl-code" spellcheck="false"></textarea>
|
||||
<p class="hint" id="pl-code-err"></p>`}
|
||||
<div class="row actions">
|
||||
${isBuiltin ? "" : `<button id="pl-code-save">保存并热重载</button>`}
|
||||
<button class="ghost" id="pl-code-close">关闭</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
wrap.addEventListener("click", (e) => {
|
||||
if (e.target === wrap) wrap.remove();
|
||||
});
|
||||
$("#pl-code-close").onclick = () => wrap.remove();
|
||||
const save = $("#pl-code-save");
|
||||
if (save) {
|
||||
save.onclick = async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
// Saving writes through POST /api/plugins — the same install path the
|
||||
// installer uses, which validates the name, compiles the source and
|
||||
// hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
|
||||
// save there would silently disable nothing and store nothing.
|
||||
await window.modelrouter.plugins.request("POST", "/api/plugins", {
|
||||
name,
|
||||
code: $("#pl-code").value,
|
||||
});
|
||||
toast(`${name} 已保存`);
|
||||
wrap.remove();
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
$("#pl-code-err").textContent = e.message;
|
||||
save.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
if (isBuiltin) return;
|
||||
(async () => {
|
||||
const ta = $("#pl-code");
|
||||
try {
|
||||
const j = await window.modelrouter.plugins.request(
|
||||
"GET",
|
||||
`/api/plugins/${encodeURIComponent(name)}`,
|
||||
);
|
||||
ta.value = j.code || "";
|
||||
const err = $("#pl-code-err");
|
||||
if (err && j.parse_error) err.textContent = String(j.parse_error);
|
||||
} catch (e) {
|
||||
$("#pl-code-err").textContent = e.message;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function deletePlugin(name) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "overlay";
|
||||
wrap.innerHTML = `<div class="overlay-card">
|
||||
<div class="overlay-head"><h2>删除插件</h2></div>
|
||||
<p>确定删除 <b>${esc(name)}</b>?插件目录中的文件会被移除,核心将不再加载它。</p>
|
||||
<div class="row actions">
|
||||
<button class="danger" id="pl-del-ok">删除</button>
|
||||
<button class="ghost" id="pl-del-no">取消</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
wrap.addEventListener("click", (e) => {
|
||||
if (e.target === wrap) wrap.remove();
|
||||
});
|
||||
$("#pl-del-no").onclick = () => wrap.remove();
|
||||
$("#pl-del-ok").onclick = async () => {
|
||||
try {
|
||||
await window.modelrouter.plugins.request(
|
||||
"DELETE",
|
||||
`/api/plugins/${encodeURIComponent(name)}`,
|
||||
);
|
||||
toast(`已删除 ${name}`);
|
||||
wrap.remove();
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// installPlugin creates a new plugin from source. The name becomes the file
|
||||
// name, so it is validated client-side to save a round trip on the obvious
|
||||
// mistakes (empty, path separators, non-portable characters).
|
||||
function installPlugin() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "overlay";
|
||||
wrap.innerHTML = `<div class="overlay-card pl-wide">
|
||||
<div class="overlay-head"><h2>安装插件</h2></div>
|
||||
<div class="row"><label>名称</label><input id="pl-new-name" placeholder="my_plugin" /></div>
|
||||
<p class="hint" id="pl-new-err"></p>
|
||||
<textarea id="pl-new-code" spellcheck="false" placeholder="-- 单文件 Lua 插件 plugin = {} ..."></textarea>
|
||||
<div class="row actions">
|
||||
<button id="pl-new-ok">安装</button>
|
||||
<button class="ghost" id="pl-new-no">取消</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
wrap.addEventListener("click", (e) => {
|
||||
if (e.target === wrap) wrap.remove();
|
||||
});
|
||||
$("#pl-new-no").onclick = () => wrap.remove();
|
||||
$("#pl-new-ok").onclick = async () => {
|
||||
const name = $("#pl-new-name").value.trim();
|
||||
const code = $("#pl-new-code").value;
|
||||
const err = $("#pl-new-err");
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
||||
err.textContent = "名称只能包含字母、数字、下划线和连字符";
|
||||
return;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
err.textContent = "源码不能为空";
|
||||
return;
|
||||
}
|
||||
$("#pl-new-ok").disabled = true;
|
||||
try {
|
||||
await window.modelrouter.plugins.request("POST", "/api/plugins", {
|
||||
// The backend appends .lua itself (installPlugin writes name+".lua"),
|
||||
// so passing it here would create name.lua.lua.
|
||||
name,
|
||||
code,
|
||||
});
|
||||
toast(`已安装 ${name}`);
|
||||
wrap.remove();
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
$("#pl-new-ok").disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function togglePlugin(name, disabled) {
|
||||
try {
|
||||
await window.modelrouter.plugins.request("PUT", `/api/plugins/${encodeURIComponent(name)}`, {
|
||||
enabled: disabled,
|
||||
});
|
||||
toast(disabled ? `已禁用 ${name}` : `已启用 ${name}`);
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAllPlugins() {
|
||||
let j;
|
||||
try {
|
||||
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
|
||||
} catch (e) {
|
||||
return toast(e.message, true);
|
||||
}
|
||||
const off = (j.on_disk || []).filter((p) => p.loaded && p.disabled);
|
||||
for (const p of off) {
|
||||
try {
|
||||
await window.modelrouter.plugins.request(
|
||||
"PUT",
|
||||
`/api/plugins/${encodeURIComponent(p.name)}`,
|
||||
{ enabled: true },
|
||||
);
|
||||
} catch (e) {
|
||||
toast(`${p.name}: ${e.message}`, true);
|
||||
}
|
||||
}
|
||||
toast(off.length ? `已启用 ${off.length} 个插件` : "没有处于禁用状态的插件");
|
||||
await loadPlugins();
|
||||
}
|
||||
|
||||
// ===== theme =====
|
||||
function applyTheme() {
|
||||
document.documentElement.dataset.theme = state.theme;
|
||||
@ -488,12 +197,6 @@ function init() {
|
||||
$("#tb-close").onclick = () => window.modelrouter.win.close();
|
||||
$("#tb-settings").onclick = openSettings;
|
||||
$("#rail-settings").onclick = openSettings;
|
||||
const plReload = document.getElementById("pl-reload");
|
||||
if (plReload) plReload.onclick = loadPlugins;
|
||||
const plAll = document.getElementById("pl-toggle-all");
|
||||
if (plAll) plAll.onclick = enableAllPlugins;
|
||||
const plInstall = document.getElementById("pl-install");
|
||||
if (plInstall) plInstall.onclick = installPlugin;
|
||||
$("#rail-autostart").onclick = toggleAutoStart;
|
||||
$("#rail-silent").onclick = toggleSilent;
|
||||
$("#rail-theme").onclick = () => {
|
||||
|
||||
@ -83,7 +83,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- side rail: autostart / silent toggles (desktop-shell style) -->
|
||||
<!-- side rail: autostart / silent toggles (Clash-Verge style) -->
|
||||
<aside class="rail" id="rail">
|
||||
<div class="rail-gap"></div>
|
||||
<button
|
||||
@ -207,16 +207,6 @@
|
||||
><input type="checkbox" id="set-tray" /> 关闭时最小化到托盘</label
|
||||
><span class="hint">点关闭按钮隐藏到系统托盘</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>插件</label>
|
||||
<span class="hint" id="set-plugins-hint">加载中…</span>
|
||||
</div>
|
||||
<div id="pl-list" class="pl-list"></div>
|
||||
<div class="row actions">
|
||||
<button class="ghost" id="pl-toggle-all">全部启用</button>
|
||||
<button class="ghost" id="pl-install">安装插件</button>
|
||||
<button class="ghost" id="pl-reload">刷新</button>
|
||||
</div>
|
||||
<div class="row actions">
|
||||
<button class="ghost" id="set-dir">打开数据目录</button>
|
||||
<button class="ghost" id="set-log">查看日志</button>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* =====================================================================
|
||||
ModelRouter Desktop GUI — desktop-shell-style shell
|
||||
ModelRouter Desktop GUI — Clash-Verge-style shell
|
||||
Content area = full-screen iframe embedding the real WebUI 1:1.
|
||||
Palette mirrors the WebUI (sakura/frost/glassmorphism).
|
||||
===================================================================== */
|
||||
@ -596,129 +596,3 @@ html[data-theme="dark"] .overlay {
|
||||
#toast.err {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* ===== plugin management panel =========================================
|
||||
* The existing .ghost/.primary rules are scoped to `.form .actions`, so a
|
||||
* button outside that selector gets browser defaults. The plugin rows live in
|
||||
* their own list, hence their own rules — reusing a scoped class here would have
|
||||
* produced unstyled buttons that still worked, which is the kind of thing that
|
||||
* looks fine until someone themes the shell.
|
||||
*/
|
||||
.pl-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.pl-item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.pl-item.pl-broken {
|
||||
border-color: var(--danger, #d1435b);
|
||||
}
|
||||
.pl-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pl-builtin {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-50);
|
||||
color: var(--primary-h);
|
||||
}
|
||||
.pl-state {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pl-item.pl-broken .pl-state {
|
||||
color: var(--danger, #d1435b);
|
||||
}
|
||||
.pl-desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.pl-err {
|
||||
font-size: 11px;
|
||||
color: var(--danger, #d1435b);
|
||||
margin-top: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pl-acts {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.pl-acts button {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-s2, #fff);
|
||||
color: var(--fg, inherit);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.pl-acts button:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-h);
|
||||
}
|
||||
.pl-empty {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
/* ---- plugin editor / installer --------------------------------------------
|
||||
The plugin source editor and the install dialog reuse the settings overlay
|
||||
(overlay / overlay-card) rather than a second popup system. They need three
|
||||
things the settings card does not: a wider column for code, a monospace
|
||||
textarea, and a destructive button for delete. */
|
||||
.overlay-card.pl-wide {
|
||||
width: 760px;
|
||||
}
|
||||
.pl-wide textarea {
|
||||
width: 100%;
|
||||
min-height: 380px;
|
||||
resize: vertical;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
|
||||
color: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
}
|
||||
.pl-wide textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.pl-wide .row input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
|
||||
color: inherit;
|
||||
}
|
||||
.pl-editor .hint,
|
||||
.pl-wide .hint {
|
||||
min-height: 16px;
|
||||
color: var(--muted, #888);
|
||||
}
|
||||
button.danger {
|
||||
color: #e5484d;
|
||||
border-color: rgba(229, 72, 77, 0.4);
|
||||
}
|
||||
button.danger:hover {
|
||||
border-color: #e5484d;
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot Windows release build for the GUI — ENTIRELY inside docker.
|
||||
#
|
||||
# 1) cross-compile the embedded core in docker (modelrouter.exe + lua51.dll)
|
||||
# 1) cross-compile the embedded core in docker (llmsproxy.exe + lua51.dll)
|
||||
# 2) electron-builder NSIS packaging ALSO inside docker (wine for the
|
||||
# uninstaller step lives in the image — see Dockerfile). This is the fix
|
||||
# for the 1.3.0/1.4.0 broken installers: the host had amd64-only wine
|
||||
@ -37,15 +37,13 @@ docker run --rm \
|
||||
-v "$ROOT":/workspace \
|
||||
-v "$CACHE_VOL":/root/.cache \
|
||||
-e GOPROXY="$PROXY" \
|
||||
-e ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/" \
|
||||
-e ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/" \
|
||||
-w /workspace \
|
||||
"$IMAGE" bash -c '
|
||||
set -euo pipefail
|
||||
cp /opt/luajit2/src/lua51.dll /workspace/cmd/gui/bin/lua51.dll
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
|
||||
go build -buildvcs=false -tags luajit -trimpath -ldflags "-s -w" \
|
||||
-o /workspace/cmd/gui/bin/modelrouter.exe ./cmd/modelrouter
|
||||
go build -buildvcs=false -tags luajit -trimpath \
|
||||
-o /workspace/cmd/gui/bin/llmsproxy.exe ./cmd/llmsproxy
|
||||
echo " -> core built"
|
||||
# wine needs one prefix initialization so its wow64 runtime (syswow64/ntdll)
|
||||
# exists — the exact thing that was missing on the broken host. Use a shared
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// Build the embedded modelrouter core(s) for the GUI package.
|
||||
// Build the embedded llmsproxy core(s) for the GUI package.
|
||||
// node prepare-core.js -> linux (native, luajit) + win (cross, best effort)
|
||||
// node prepare-core.js linux -> linux only
|
||||
// node prepare-core.js win -> win only
|
||||
@ -12,24 +12,21 @@ const binDir = path.join(guiDir, "bin");
|
||||
const projectRoot = path.join(guiDir, "..", "..");
|
||||
|
||||
function buildLinux() {
|
||||
const out = path.join(binDir, "modelrouter");
|
||||
console.log("[prepare-core] building modelrouter (linux/luajit)...");
|
||||
const out = path.join(binDir, "llmsproxy");
|
||||
console.log("[prepare-core] building llmsproxy (linux/luajit)...");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
execFileSync(
|
||||
"go",
|
||||
// -s -w: same stripping as packaging/core-dist.sh. Without it the bundled
|
||||
// core carries debug info (~14.4 MB vs ~10.3 MB) and every installer pays
|
||||
// for it, for a binary the user never debugs.
|
||||
["build", "-tags", "luajit", "-trimpath", "-ldflags", "-s -w", "-o", out, "./cmd/modelrouter"],
|
||||
["build", "-tags", "luajit", "-trimpath", "-o", out, "./cmd/llmsproxy"],
|
||||
{ cwd: projectRoot, stdio: "inherit" },
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildWin() {
|
||||
const out = path.join(binDir, "modelrouter.exe");
|
||||
const out = path.join(binDir, "llmsproxy.exe");
|
||||
console.log(
|
||||
"[prepare-core] cross-building modelrouter.exe (windows/amd64, luajit)...",
|
||||
"[prepare-core] cross-building llmsproxy.exe (windows/amd64, luajit)...",
|
||||
);
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const cc = process.env.MINGW_CC || "x86_64-w64-mingw32-gcc";
|
||||
@ -46,7 +43,7 @@ function buildWin() {
|
||||
}
|
||||
execFileSync(
|
||||
"go",
|
||||
["build", "-tags", "luajit", "-trimpath", "-ldflags", "-s -w", "-o", out, "./cmd/modelrouter"],
|
||||
["build", "-tags", "luajit", "-trimpath", "-o", out, "./cmd/llmsproxy"],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
|
||||
107
cmd/llmsproxy/main.go
Normal file
107
cmd/llmsproxy/main.go
Normal file
@ -0,0 +1,107 @@
|
||||
// Command llmsproxy is a standalone gateway that exposes multiple upstream LLM
|
||||
// sources behind a unified OpenAI-compatible HTTP API. Protocol differences are
|
||||
// handled by Lua adapters (per source), optionally signing outgoing requests.
|
||||
// It supports explicit model selection or AUTO routing by priority, image
|
||||
// generation, multimodal payloads, a web UI for adapter/source management, and
|
||||
// concurrent/queued scheduling with backoff and failover.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
"llmsproxy/internal/gateway"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := flag.String("config", "config.yaml", "path to gateway config file")
|
||||
checkOnly := flag.Bool("check", false, "validate the config file and exit (0 = valid, 1 = invalid); nothing is started and no file is written")
|
||||
flag.Parse()
|
||||
|
||||
// -check is the deploy-time preflight: parse and validate the config
|
||||
// without starting the Lua VM, touching runtime.json, or binding a port.
|
||||
// It deliberately does NOT call EnsureDefault, so a missing file is an
|
||||
// error here instead of being silently created.
|
||||
if *checkOnly {
|
||||
if _, err := os.Stat(*cfgPath); err != nil {
|
||||
log.Fatalf("[llmsproxy] check: %v", err)
|
||||
}
|
||||
if _, err := config.Load(*cfgPath); err != nil {
|
||||
log.Fatalf("[llmsproxy] check: %v", err)
|
||||
}
|
||||
log.Printf("[llmsproxy] check: %s is valid", *cfgPath)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := config.EnsureDefault(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[llmsproxy] config: %v", err)
|
||||
}
|
||||
|
||||
c, err := core.New(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[llmsproxy] core: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if created {
|
||||
for _, k := range c.GatewayKeys() {
|
||||
log.Printf("[llmsproxy] generated default config at %s — admin key: %s (rotate it in the WebUI after first login)", *cfgPath, k)
|
||||
}
|
||||
}
|
||||
|
||||
gw, err := gateway.New(c, c.GatewayKeys())
|
||||
if err != nil {
|
||||
log.Fatalf("[llmsproxy] gateway: %v", err)
|
||||
}
|
||||
|
||||
// Ops hygiene: surface the two most common footguns instead of silently
|
||||
// running with them.
|
||||
if keys := c.GatewayKeys(); len(keys) == 0 {
|
||||
log.Printf("[llmsproxy] WARNING: gateway_keys is EMPTY — without a key every request is rejected")
|
||||
} else {
|
||||
for _, k := range keys {
|
||||
if k == "sk-gw-local-0001" || k == "sk-local-0001" {
|
||||
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if l := c.Listen(); strings.HasPrefix(l, "0.0.0.0:") || strings.HasPrefix(l, "::") {
|
||||
log.Printf("[llmsproxy] WARNING: listen=%s binds ALL interfaces — bind an internal address in production", l)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: c.Listen(),
|
||||
Handler: gw.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
cert, key := c.TLS()
|
||||
if cert != "" && key != "" {
|
||||
log.Printf("[llmsproxy] listening HTTPS on %s (cert=%q) (default_model=%s, models=%v, adapters=%d)",
|
||||
c.Listen(), cert, c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters()))
|
||||
if err := srv.ListenAndServeTLS(cert, key); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[llmsproxy] server: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("[llmsproxy] listening HTTP on %s (default_model=%s, models=%v, adapters=%d)",
|
||||
c.Listen(), c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters()))
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[llmsproxy] server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Printf("[llmsproxy] shutting down")
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
// Command modelrouter is a standalone gateway that exposes multiple upstream LLM
|
||||
// sources behind a unified OpenAI-compatible HTTP API. Protocol differences are
|
||||
// handled by Lua adapters (per source), optionally signing outgoing requests.
|
||||
// It supports explicit model selection or AUTO routing by priority, image
|
||||
// generation, multimodal payloads, a web UI for adapter/source management, and
|
||||
// concurrent/queued scheduling with backoff and failover.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
"modelrouter/internal/gateway"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := flag.String("config", "config.yaml", "path to gateway config file")
|
||||
checkOnly := flag.Bool("check", false, "validate the config file and exit (0 = valid, 1 = invalid); nothing is started and no file is written")
|
||||
showSecrets := flag.Bool("show-secrets", false, "print the config's credentials in the clear and exit; nothing is started and no file is written")
|
||||
flag.Parse()
|
||||
|
||||
// -check is the deploy-time preflight: parse and validate the config
|
||||
// without starting the Lua VM, touching runtime.json, or binding a port.
|
||||
// It deliberately does NOT call EnsureDefault, so a missing file is an
|
||||
// error here instead of being silently created.
|
||||
if *checkOnly {
|
||||
if _, err := os.Stat(*cfgPath); err != nil {
|
||||
log.Fatalf("[modelrouter] check: %v", err)
|
||||
}
|
||||
if _, err := config.Load(*cfgPath); err != nil {
|
||||
log.Fatalf("[modelrouter] check: %v", err)
|
||||
}
|
||||
log.Printf("[modelrouter] check: %s is valid", *cfgPath)
|
||||
return
|
||||
}
|
||||
|
||||
// -show-secrets is the operator escape hatch for a config whose
|
||||
// credentials are sealed at rest: it prints them to stdout and exits
|
||||
// without starting anything or writing anything. Nothing else in the
|
||||
// program prints a credential, and this path never logs to a file.
|
||||
if *showSecrets {
|
||||
if _, err := os.Stat(*cfgPath); err != nil {
|
||||
log.Fatalf("[modelrouter] show-secrets: %v", err)
|
||||
}
|
||||
if err := config.PrintSecrets(*cfgPath); err != nil {
|
||||
log.Fatalf("[modelrouter] show-secrets: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
created, err := config.EnsureDefault(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[modelrouter] config: %v", err)
|
||||
}
|
||||
|
||||
c, err := core.New(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[modelrouter] core: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if created {
|
||||
for _, k := range c.GatewayKeys() {
|
||||
log.Printf("[modelrouter] generated default config at %s — admin key: %s (rotate it in the WebUI after first login)", *cfgPath, k)
|
||||
}
|
||||
}
|
||||
|
||||
gw, err := gateway.New(c)
|
||||
if err != nil {
|
||||
log.Fatalf("[modelrouter] gateway: %v", err)
|
||||
}
|
||||
|
||||
// Ops hygiene: surface the two most common footguns instead of silently
|
||||
// running with them.
|
||||
//
|
||||
// Read the AUTHORITATIVE source. Auth uses core.ListKeys() (cfg.Keys), not
|
||||
// the legacy cfg.GatewayKeys list: seedKeys copies gateway_keys into keys[]
|
||||
// on first start and the YAML list is ignored for auth afterwards. Checking
|
||||
// GatewayKeys() here made the warning lie — once the legacy list was empty
|
||||
// (e.g. after rotating the starter key away) it reported "every request is
|
||||
// rejected" while seven working keys, one of them admin, were in service.
|
||||
if keys := c.ListKeys(); len(keys) == 0 {
|
||||
log.Printf("[modelrouter] WARNING: no gateway key configured — every request will be rejected. Add one in the WebUI or set gateway_keys")
|
||||
} else {
|
||||
for _, k := range keys {
|
||||
if k.Key == "sk-gw-local-0001" || k.Key == "sk-local-0001" {
|
||||
log.Printf("[modelrouter] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if l := c.Listen(); strings.HasPrefix(l, "0.0.0.0:") || strings.HasPrefix(l, "::") {
|
||||
log.Printf("[modelrouter] WARNING: listen=%s binds ALL interfaces — bind an internal address in production", l)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: c.Listen(),
|
||||
Handler: gw.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
cert, key := c.TLS()
|
||||
if cert != "" && key != "" {
|
||||
log.Printf("[modelrouter] listening HTTPS on %s (cert=%q) (default_model=%s, models=%v, adapters=%d)",
|
||||
c.Listen(), cert, c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters()))
|
||||
if err := srv.ListenAndServeTLS(cert, key); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[modelrouter] server: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("[modelrouter] listening HTTP on %s (default_model=%s, models=%v, adapters=%d)",
|
||||
c.Listen(), c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters()))
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[modelrouter] server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Printf("[modelrouter] shutting down")
|
||||
}
|
||||
247
deploy.sh
247
deploy.sh
@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# modelrouter 原子化滚动部署脚本
|
||||
# llmsproxy 原子化滚动部署脚本
|
||||
#
|
||||
# 策略:
|
||||
# 1. /tmp (tmpfs) 构建,/usr/local/bin (ext4) 部署
|
||||
# 2. 构建后先 copy 到目标文件系统(.modelrouter.tmp),
|
||||
# 2. 构建后先 copy 到目标文件系统(.llmsproxy.tmp),
|
||||
# 再 rename(2) 做原子替换(同一文件系统内 mv 是原子的)
|
||||
# 3. runtime.json 不替换(保留运行时密钥、审计状态)
|
||||
# 4. systemctl restart → 服务中断仅在 Go 进程重启瞬间 (<500ms)
|
||||
@ -28,24 +28,15 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- 配置 ----------
|
||||
readonly TARGET_BIN="/usr/local/bin/modelrouter"
|
||||
readonly TARGET_CONFIG="/etc/modelrouter/config.yaml"
|
||||
readonly TARGET_ADAPTERS="/etc/modelrouter/adapters"
|
||||
readonly TARGET_PLUGINS="/etc/modelrouter/plugins"
|
||||
# 仓库所在目录不等于产品名:这里保持真实路径,除非用 MODELROUTER_REPO_DIR 覆盖。
|
||||
readonly REPO_DIR="${MODELROUTER_REPO_DIR:-/home/program/llmsproxy}"
|
||||
readonly TARGET_BIN="/usr/local/bin/llmsproxy"
|
||||
readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml"
|
||||
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
|
||||
readonly REPO_DIR="/home/program/llmsproxy"
|
||||
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
||||
readonly BIN_NAME="modelrouter"
|
||||
readonly BIN_NAME="llmsproxy"
|
||||
|
||||
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
|
||||
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
|
||||
# 可用 MODELROUTER_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
|
||||
readonly BUILD_SUBDIR=".build-work"
|
||||
if [[ -n "${MODELROUTER_BUILD_DIR:-}" ]]; then
|
||||
readonly TMP_DIR="${MODELROUTER_BUILD_DIR}"
|
||||
else
|
||||
readonly TMP_DIR="${REPO_DIR}/${BUILD_SUBDIR}"
|
||||
fi
|
||||
# 构建目录在 /tmp(tmpfs 更快)
|
||||
readonly TMP_DIR="/tmp/llmsproxy-deploy"
|
||||
# 同文件系统暂存文件(用于原子 rename)
|
||||
readonly STAGING_BIN="${TARGET_BIN}.staging"
|
||||
readonly STAGING_CONFIG="${TARGET_CONFIG}.staging"
|
||||
@ -60,7 +51,7 @@ usage() {
|
||||
cat <<'EOF'
|
||||
用法: ./deploy.sh [--config <file>]
|
||||
|
||||
--config <file> 投放这份配置到 /etc/modelrouter/config.yaml(原子替换)。
|
||||
--config <file> 投放这份配置到 /etc/llmsproxy/config.yaml(原子替换)。
|
||||
省略时不改动线上配置,只部署二进制与适配器。
|
||||
无论是否指定,重启前都会用新二进制 -check 预检线上配置。
|
||||
|
||||
@ -104,34 +95,6 @@ precheck() {
|
||||
command -v systemctl &>/dev/null || fail "未找到 systemctl"
|
||||
[[ $(id -u) -eq 0 ]] || fail "需要 root 权限"
|
||||
|
||||
# 改名保护:本脚本现在部署到 /etc/modelrouter。若机器上仍是改名前的
|
||||
# /etc/llmsproxy 且新路径尚未建立,直接继续会创建一份**空配置**并让新
|
||||
# 服务在没有 key/源的情况下起来 —— 看起来成功,实际网关拒掉所有请求。
|
||||
if [[ -d /etc/llmsproxy && ! -e "$TARGET_CONFIG" ]]; then
|
||||
fail "检测到改名前的部署 /etc/llmsproxy,但 $TARGET_CONFIG 不存在。
|
||||
先迁移(dry-run 默认,确认后加 --apply):
|
||||
sudo ./packaging/migrate-to-modelrouter.sh
|
||||
sudo ./packaging/migrate-to-modelrouter.sh --apply
|
||||
如需全新部署,先把旧目录移开:mv /etc/llmsproxy /etc/llmsproxy.bak"
|
||||
fi
|
||||
|
||||
# unit 与二进制路径必须一致。包安装把二进制放 /usr/bin,源码部署放
|
||||
# /usr/local/bin;unit 里写错一行,systemctl restart 只会给 203/EXEC,
|
||||
# 看起来像"部署成功但服务起不来"。此处提前拦住。
|
||||
local unit="/etc/systemd/system/modelrouter.service"
|
||||
if [[ -f "$unit" ]]; then
|
||||
local unit_exe
|
||||
unit_exe=$(awk -F'=' '/^ExecStart=/{print $2; exit}' "$unit" | awk '{print $1}')
|
||||
if [[ -n "$unit_exe" && "$unit_exe" != "$TARGET_BIN" && ! -x "$unit_exe" ]]; then
|
||||
fail "unit 的 ExecStart 指向 $unit_exe,但该文件不存在。
|
||||
包安装(deb/rpm)用 /usr/bin/modelrouter;源码部署(本脚本)用 $TARGET_BIN。
|
||||
请把 $unit 的 ExecStart 改成 $TARGET_BIN,或改用包安装。"
|
||||
fi
|
||||
if [[ -n "$unit_exe" && "$unit_exe" != "$TARGET_BIN" ]]; then
|
||||
warn "unit ExecStart=$unit_exe,而本脚本部署到 $TARGET_BIN(两者都存在,确认哪个该生效)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_CONFIG_SRC" ]]; then
|
||||
[[ -f "$NEW_CONFIG_SRC" ]] || fail "待部署配置不存在: $NEW_CONFIG_SRC"
|
||||
# 同一文件比对:--config 指向线上文件本身时没有"投放"动作可做,
|
||||
@ -158,14 +121,14 @@ backup_old_state() {
|
||||
log "备份旧二进制到 $BACKUP_BIN"
|
||||
if [[ -f "$TARGET_BIN" ]]; then
|
||||
cp -f "$TARGET_BIN" "$BACKUP_BIN"
|
||||
log "OK 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
|
||||
log "✓ 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
|
||||
else
|
||||
warn "旧二进制不存在,跳过备份(无回滚点)"
|
||||
fi
|
||||
|
||||
if [[ -f "$TARGET_CONFIG" ]]; then
|
||||
cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"
|
||||
log "OK 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
log "✓ 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
else
|
||||
warn "线上配置不存在: $TARGET_CONFIG(首次部署?无配置回滚点)"
|
||||
fi
|
||||
@ -178,11 +141,11 @@ build() {
|
||||
mkdir -p "$TMP_DIR/bin"
|
||||
|
||||
cd "$REPO_DIR"
|
||||
CGO_ENABLED=1 GOTMPDIR="$TMP_DIR" go build -tags luajit \
|
||||
CGO_ENABLED=1 go build -tags luajit \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$TMP_DIR/bin/$BIN_NAME" \
|
||||
./cmd/modelrouter
|
||||
./cmd/llmsproxy
|
||||
|
||||
NEW_BIN="$TMP_DIR/bin/$BIN_NAME"
|
||||
NEW_SIZE=$(stat -c%s "$NEW_BIN")
|
||||
@ -211,9 +174,9 @@ build() {
|
||||
)
|
||||
for c in "${checks[@]}"; do
|
||||
if strings "$NEW_BIN" | grep -qF "$c"; then
|
||||
log " OK 新二进制包含: $c"
|
||||
log " ✓ 新二进制包含: $c"
|
||||
else
|
||||
warn " 注意: 新二进制不包含: $c(非预期但非致命)"
|
||||
warn " ⚠ 新二进制不包含: $c(非预期但非致命)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
@ -243,7 +206,7 @@ atomic_replace_binary() {
|
||||
|
||||
# 验证
|
||||
NEW_SHA256=$(sha256sum "$TARGET_BIN" | awk '{print $1}')
|
||||
log "OK 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
|
||||
log "✓ 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
|
||||
}
|
||||
|
||||
# ---------- 原子替换配置 ----------
|
||||
@ -259,7 +222,7 @@ atomic_replace_config() {
|
||||
|
||||
log " rename → $TARGET_CONFIG (原子)"
|
||||
mv -f "$STAGING_CONFIG" "$TARGET_CONFIG"
|
||||
log "OK 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
log "✓ 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
}
|
||||
|
||||
# ---------- 配置预检 ----------
|
||||
@ -272,7 +235,7 @@ verify_config() {
|
||||
|
||||
local out
|
||||
if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then
|
||||
log "OK 配置合法"
|
||||
log "✓ 配置合法"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@ -281,137 +244,6 @@ verify_config() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 密钥可解封校验 ----------
|
||||
# config.yaml 里的凭据以 enc:v1: 密文落盘,解密钥匙是 master.key(或
|
||||
# MODELROUTER_MASTER_KEY)。一旦钥匙丢失/错位,配置在**启动时**才会报错,
|
||||
# 那时服务已经处于「二进制已替换 + 配置已替换」的半死状态,只能靠回滚。
|
||||
# 所以在替换任何东西之前先验:能不能用现有的钥匙解开现有的密文。
|
||||
#
|
||||
# 两种情形要分开:
|
||||
# 1. 配置里**有**密文 ⇒ 钥匙必须能解开(否则拒绝部署)
|
||||
# 2. 配置里**没有**密文 ⇒ 全新安装/首次加密,无需钥匙(但会顺手记一条,
|
||||
# 因为它意味着这次部署会把明文写成密文,而钥匙必须同时就位)
|
||||
verify_master_key() {
|
||||
log "密钥可解封校验"
|
||||
[[ -f "$TARGET_CONFIG" ]] || return 0 # 配置都不在,交给 verify_config 报错
|
||||
|
||||
local sealed
|
||||
sealed=$(grep -c 'enc:v1:' "$TARGET_CONFIG" 2>/dev/null || echo 0)
|
||||
if [[ "$sealed" -eq 0 ]]; then
|
||||
warn "配置内无密文(尚未加密或全为明文)——首次加密需要 master.key 可写"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 钥匙来源与 config 包一致:env 优先,否则 runtime_file 同目录的 master.key
|
||||
local runtime_file key_file
|
||||
runtime_file=$(awk '/^runtime_file:/{print $2; exit}' "$TARGET_CONFIG")
|
||||
[[ -n "$runtime_file" ]] || runtime_file="$(dirname "$TARGET_CONFIG")/runtime.json"
|
||||
key_file="$(dirname "$runtime_file")/master.key"
|
||||
|
||||
MASTER_KEY_ENV="${MODELROUTER_MASTER_KEY:-${LLMS_PROXY_MASTER_KEY:-}}"
|
||||
if [[ -n "$MASTER_KEY_ENV" ]]; then
|
||||
log " 钥匙来源: MODELROUTER_MASTER_KEY(环境变量)"
|
||||
# 走真实解密路径验证,而不是只检查格式
|
||||
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
|
||||
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
return 0
|
||||
fi
|
||||
err "凭据无法用 MODELROUTER_MASTER_KEY 解封,未替换任何文件:"
|
||||
printf '%s\n' "$out" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$key_file" ]]; then
|
||||
err "配置含 $sealed 处密文,但钥匙文件不存在: $key_file"
|
||||
err " 没有它,部署后服务将无法启动。恢复 master.key 或设置 MODELROUTER_MASTER_KEY 后重试。"
|
||||
return 1
|
||||
fi
|
||||
log " 钥匙文件: $key_file ($(stat -c '%A' "$key_file" 2>/dev/null || echo '?'),$(wc -c < "$key_file") 字节)"
|
||||
|
||||
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
|
||||
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
return 0
|
||||
fi
|
||||
err "凭据无法解密(钥匙与配置不匹配),未替换任何文件:"
|
||||
printf '%s\n' "$out" | head -5 >&2
|
||||
err " 密钥来源: $key_file"
|
||||
err " 常见原因:master.key 被重新生成、恢复了不匹配的备份,或换了机器。"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 备份目录保留策略 ----------
|
||||
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
|
||||
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
|
||||
# 实测线上已积累 71 个,/etc/modelrouter 因此涨到 122M。
|
||||
#
|
||||
# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置
|
||||
# 就可能在几秒内造出成百上千个目录。
|
||||
# 只删名字严格匹配 <prefix>.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
|
||||
#
|
||||
# ★ 必须**同时覆盖 adapters 与 plugins**(同一缺陷的两个实例):
|
||||
# 本函数最初只处理 adapters.bak,而 sync_plugins 同样每次建 plugins.bak.*
|
||||
# —— 于是修了 adapters 的堆积、plugins 继续涨(实测线上 36 个、占用 192M)。
|
||||
# 这正是「两处只修一处」的老形状:给一个新前缀加备份目录时,必须同步纳入
|
||||
# prune_all_backups,否则它就是一个不会被清理的新泄漏点。
|
||||
KEEP_BACKUPS=5
|
||||
|
||||
prune_backups() {
|
||||
local prefix="$1"
|
||||
local base="/etc/modelrouter"
|
||||
[[ -d "$base" ]] || return 0
|
||||
# 防误用:前缀必须是已知的两个(避免有人传空串删掉整个目录)
|
||||
case "$prefix" in
|
||||
adapters|plugins) ;;
|
||||
*) fail "prune_backups: 未知前缀 '$prefix'(只接受 adapters|plugins)" ;;
|
||||
esac
|
||||
|
||||
# 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。
|
||||
local all
|
||||
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
|
||||
local cap=$((KEEP_BACKUPS * 4))
|
||||
if (( ${#all[@]} > cap )); then
|
||||
warn "$prefix 备份目录有 ${#all[@]} 个(异常),裁到 $cap"
|
||||
local i=0
|
||||
for d in "${all[@]}"; do
|
||||
i=$((i + 1))
|
||||
# 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。
|
||||
if (( i <= ${#all[@]} - cap )); then
|
||||
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
|
||||
rm -rf "${base:?}/$d"
|
||||
else
|
||||
warn "跳过名字不规范的备份目录(不删): $d"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
|
||||
fi
|
||||
|
||||
# 再按时间保留最近 KEEP_BACKUPS 份(sort 后最新在末尾)。
|
||||
local total=${#all[@]} i=0
|
||||
for d in "${all[@]}"; do
|
||||
i=$((i + 1))
|
||||
# i <= total-KEEP 的是较旧的,要删。
|
||||
if (( i <= total - KEEP_BACKUPS )); then
|
||||
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
|
||||
rm -rf "${base:?}/$d"
|
||||
else
|
||||
warn "跳过名字不规范的备份目录(不删): $d"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
local left
|
||||
left=$(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" | wc -l)
|
||||
log " $prefix 备份保留最近 $KEEP_BACKUPS 份(当前剩 $left 个)"
|
||||
}
|
||||
|
||||
# prune_all_backups:两个前缀都裁。
|
||||
# ★ 新增任何 .bak.<时间戳> 目录的前缀时,必须在这里补上 —— 否则又一个泄漏点。
|
||||
prune_all_backups() {
|
||||
prune_backups adapters
|
||||
prune_backups plugins
|
||||
}
|
||||
|
||||
# ---------- 同步适配器 ----------
|
||||
sync_adapters() {
|
||||
log "同步适配器"
|
||||
@ -423,42 +255,34 @@ sync_adapters() {
|
||||
[[ -d "$SRC_ADAPTERS" ]] || fail "内嵌适配器目录不存在: $SRC_ADAPTERS"
|
||||
|
||||
# 备份旧的适配器文件(含任何运行时覆盖版本,如 opencode.lua)
|
||||
BACKUP_DIR="/etc/modelrouter/adapters.bak.$(date +%Y%m%d%H%M%S)"
|
||||
BACKUP_DIR="/etc/llmsproxy/adapters.bak.$(date +%Y%m%d%H%M%S)"
|
||||
if [[ -d "$TARGET_ADAPTERS" ]]; then
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
|
||||
log " 旧适配器已备份到 $BACKUP_DIR"
|
||||
fi
|
||||
prune_all_backups
|
||||
|
||||
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
|
||||
chmod 0644 "$TARGET_ADAPTERS/"*.lua
|
||||
|
||||
# 校验:每个适配器必须包含 usage 透传修复(opencode/openai/deepseek 等应有 'uses')
|
||||
local required_files=(openai deepseek anthropic gemini ollama github groq mistral sensenova qwen38)
|
||||
local required_files=(openai deepseek anthropic gemini ollama opencode github groq kimicode mistral sensenova agentrouter)
|
||||
for f in "${required_files[@]}"; do
|
||||
[[ -f "$TARGET_ADAPTERS/$f.lua" ]] || warn " 缺少适配器: $f.lua"
|
||||
done
|
||||
local usage_fixed=0
|
||||
for f in openai deepseek github groq mistral; do
|
||||
for f in openai deepseek github groq kimicode mistral opencode; do
|
||||
grep -qE '\buses\b' "$TARGET_ADAPTERS/$f.lua" 2>/dev/null && ((usage_fixed++)) || true
|
||||
done
|
||||
log " usage 透传修复适配器: ${usage_fixed}/5 (openai/deepseek/github/groq/mistral)"
|
||||
log "OK 适配器已同步到 $TARGET_ADAPTERS/"
|
||||
log " usage 透传修复适配器: ${usage_fixed}/7 (openai/deepseek/github/groq/kimicode/mistral/opencode)"
|
||||
log "✓ 适配器已同步到 $TARGET_ADAPTERS/"
|
||||
}
|
||||
|
||||
# 插件不在这里同步。
|
||||
#
|
||||
# 早期实现会把内置插件复制/覆盖到 plugin_dir,理由是"内置插件是发行物的一部分"。
|
||||
# 该前提已被废止:插件不再编入二进制、不进发行包、不自动安装,属于运维自有资产。
|
||||
# 部署脚本只负责适配器(协议转换,随内核发布)与二进制/配置,绝不创建、覆盖或
|
||||
# 删除 plugin_dir 里的任何文件。
|
||||
|
||||
# ---------- 重启服务 ----------
|
||||
restart_service() {
|
||||
log "重启服务(中断 <500ms)"
|
||||
systemctl restart modelrouter.service || fail "systemd restart 失败"
|
||||
log "OK modelrouter.service restarted"
|
||||
systemctl restart llmsproxy.service || fail "systemd restart 失败"
|
||||
log "✓ llmsproxy.service restarted"
|
||||
}
|
||||
|
||||
# ---------- 健康检查 ----------
|
||||
@ -473,7 +297,7 @@ healthcheck() {
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$HEALTH_URL" 2>/dev/null || echo "000")
|
||||
if [[ "$code" == "200" || "$code" == "401" ]]; then
|
||||
log "OK 服务健康 (status=$code, ${attempt}/${attempts})"
|
||||
log "✓ 服务健康 (status=$code, ${attempt}/${attempts})"
|
||||
return 0
|
||||
fi
|
||||
log " 等待中 ($attempt/$attempts, status=$code)"
|
||||
@ -481,7 +305,7 @@ healthcheck() {
|
||||
(( attempt++ ))
|
||||
done
|
||||
|
||||
warn "健康检查未通过,请检查服务状态: systemctl status modelrouter.service"
|
||||
warn "健康检查未通过,请检查服务状态: systemctl status llmsproxy.service"
|
||||
return 1
|
||||
}
|
||||
|
||||
@ -517,7 +341,7 @@ rollback() {
|
||||
|
||||
if (( restored_any == 0 )); then
|
||||
err "无任何回滚点,无法自动恢复"
|
||||
err "请手动检查: systemctl status modelrouter.service && journalctl -u modelrouter -n 50"
|
||||
err "请手动检查: systemctl status llmsproxy.service && journalctl -u llmsproxy -n 50"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@ -531,9 +355,9 @@ rollback() {
|
||||
fi
|
||||
|
||||
log "重启服务(旧版本)"
|
||||
systemctl restart modelrouter.service || {
|
||||
systemctl restart llmsproxy.service || {
|
||||
err "重启失败,服务可能完全宕机"
|
||||
err "请手动检查: systemctl status modelrouter.service"
|
||||
err "请手动检查: systemctl status llmsproxy.service"
|
||||
return 1
|
||||
}
|
||||
|
||||
@ -542,7 +366,7 @@ rollback() {
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "000")
|
||||
if [[ "$code" == "200" || "$code" == "401" ]]; then
|
||||
log "OK 回滚成功,旧版本已恢复服务 (status=$code)"
|
||||
log "✓ 回滚成功,旧版本已恢复服务 (status=$code)"
|
||||
log " 新版本部署失败,请检查构建/配置后重试"
|
||||
return 0
|
||||
else
|
||||
@ -559,7 +383,6 @@ print_summary() {
|
||||
log " 二进制: $TARGET_BIN"
|
||||
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
|
||||
log " 适配器: $TARGET_ADAPTERS"
|
||||
log " 插件: $TARGET_PLUGINS"
|
||||
log "═════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
@ -568,12 +391,8 @@ print_summary() {
|
||||
# 于是坏配置在服务仍然健康时就被拦下,根本不会触发重启。
|
||||
main() {
|
||||
parse_args "$@"
|
||||
log "═══ modelrouter 原子部署开始 ═══"
|
||||
log "═══ llmsproxy 原子部署开始 ═══"
|
||||
precheck
|
||||
# 密钥校验放在 build/替换之前:它的失败后果最重(配置里的密文永久解不开),
|
||||
# 必须在动任何文件之前就挡住。此阶段用的是**当前线上二进制**,它已经
|
||||
# 支持 -show-secrets(首次引入该子命令的版本除外,此时跳过并提示)。
|
||||
verify_master_key
|
||||
build
|
||||
sync_adapters
|
||||
backup_old_state
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
# 适配器协议
|
||||
|
||||
> 本文档由 README 拆分而来:Lua 适配器协议与内置适配器清单。
|
||||
> 编写适配器的完整参考见 [lua-adapters.md](lua-adapters.md)。
|
||||
> 返回 [README](../README.md)。
|
||||
|
||||
## Lua 适配器协议
|
||||
|
||||
完整 API 见 **[Lua 适配器 API 文档](docs/lua-adapters.md)**([English](docs/lua-adapters-en.md))。
|
||||
|
||||
每个适配器是一个返回 table 的 Lua 脚本(`internal/lua/adapters/<name>.lua`),
|
||||
加载时可被 `adapter_dir` 下的同名脚本覆盖——**改 Lua 脚本无需重编译**,
|
||||
重启即生效;通过 WebUI 上传的适配器与在线编辑的源配置则即时生效。
|
||||
|
||||
```lua
|
||||
return {
|
||||
name = "mysrc",
|
||||
version = "1.0.0",
|
||||
endpoint = "/chat/completions", -- 上游路径(可被 source.endpoint 覆盖)
|
||||
headers = { ["X-Static"] = "v" }, -- 静态默认请求头(build_headers 缺省时使用)
|
||||
|
||||
-- 请求转换:把 OpenAI 格式 req 转成上游原生格式,返回字符串
|
||||
transform_request = function(raw_json) ... end,
|
||||
|
||||
-- 响应转换:把上游原生响应转成统一格式字符串
|
||||
-- { content, reasoning_content, finish_reason, token_usage{...}, tool_calls[{...}] }
|
||||
transform_response = function(raw_json) ... end,
|
||||
|
||||
-- 流式分块转换:把上游 SSE data 转成 { content, done, ... },返回 "" 则跳过
|
||||
transform_stream_chunk = function(raw_chunk) ... end,
|
||||
|
||||
-- [可选] 动态请求头/签名钩子
|
||||
-- meta = { url, method, body, api_key, timestamp, source={ name, meta={...} } }
|
||||
build_headers = function(meta) return { ["X-App-Sign"] = sign } end,
|
||||
|
||||
-- [可选] 出向序列化(契约 v2):上游不是 OpenAI 协议时接管写给客户端的字节。
|
||||
-- 不声明则沿用内核的 OpenAI 兜底;声明即完全取代,内核不再混合字段。
|
||||
serialize_response = function(result) return body_str end,
|
||||
serialize_open_preamble = function(result) return bytes end,
|
||||
serialize_chunk = function(delta) return bytes end,
|
||||
serialize_close = function(result) return bytes end,
|
||||
}
|
||||
```
|
||||
|
||||
内置信号辅助:`hmac_sha256_hex(key, data)`、`sha256_hex(data)`、`base64_encode(s)`、
|
||||
`tohex(s)`、`json.encode/decode`、`log(level, msg)`。
|
||||
|
||||
### 内置适配器
|
||||
|
||||
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `qwen38` `sensenova`
|
||||
`qwen38` `sensenova` `trae`。
|
||||
|
||||
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);
|
||||
推理开关由中立的 `thinking` 字段表达,`deepseek` 适配器把它映射为
|
||||
`thinking.type = "disabled"`。
|
||||
|
||||
适配器可用 `build_headers(meta)` 注入签名头。示例见 `adapters/` 下任一脚本的
|
||||
`build_headers` 实现;云端校验调用方 app 时需要按
|
||||
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
|
||||
配好 `sources[].meta.{app_id, app_secret, app_agent}` 即可。
|
||||
|
||||
@ -1,183 +0,0 @@
|
||||
# 配置
|
||||
|
||||
> 本文档由 README 拆分而来:网关配置、模型自动探测、多密钥。
|
||||
> 返回 [README](../README.md)。
|
||||
|
||||
## 配置文件
|
||||
|
||||
首次运行在 `-config` 路径自动生成默认配置(默认 `./config.yaml`),核心字段:
|
||||
|
||||
```yaml
|
||||
listen: 127.0.0.1:8080 # 网关监听地址(建议绑内网/回环)
|
||||
gateway_keys: [sk-gw-<随机>] # 初始 admin 密钥(每次安装随机生成,仅首启写入运行时存储用)
|
||||
default_model: AUTO # model 无法路由时自动按优先级链选源
|
||||
adapter_dir: adapters # Lua 适配目录;目录不存在时首启创建并 seed 内置,存在则只读
|
||||
runtime_file: runtime.json # WebUI 编辑的源/密钥/AUTO 链持久化到此文件
|
||||
|
||||
sources:
|
||||
- name: deepseek
|
||||
base_url: https://api.deepseek.com
|
||||
api_key: sk-... # 也可用 api_key_env: SOME_ENV 引用环境变量(不落盘明文)
|
||||
adapter: deepseek
|
||||
max_concurrent: 8
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
priority: 100 # YAML 源首次启动会 seed 进 AUTO 链的初值
|
||||
kind: chat
|
||||
- id: deepseek-v4-pro
|
||||
priority: 60
|
||||
kind: chat
|
||||
# 静态请求头(比适配器默认优先)
|
||||
headers: { X-Tenant: prod }
|
||||
# 传给 Lua build_headers 的透传元数据
|
||||
meta: { app_id: x, app_secret: y }
|
||||
temperature: 0.7
|
||||
max_tokens: 4096
|
||||
timeout: 120s # 请求超时,默认 120s
|
||||
```
|
||||
|
||||
|
||||
## 自动探测模型列表
|
||||
|
||||
不想手抄模型 id 时,只填 URL 和 key,让网关去扫:
|
||||
|
||||
```bash
|
||||
# 只探测,不写任何东西
|
||||
curl -X POST http://127.0.0.1:8080/api/sources/discover \
|
||||
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
|
||||
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…"}'
|
||||
# → {"ok":true,"base_url":"…","endpoint":"","models":["gpt-x",…],
|
||||
# "image_models":["gpt-image-2"],"models_path":"/models","latency_ms":1387,…}
|
||||
|
||||
# 探测后直接落库
|
||||
curl -X POST http://127.0.0.1:8080/api/sources/discover/apply \
|
||||
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
|
||||
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…","name":"vendor"}'
|
||||
```
|
||||
|
||||
WebUI 源编辑框里也有「自动探测模型」按钮:填好 URL 和 key 点一下,列表自动
|
||||
填好,仍需点保存才生效。
|
||||
|
||||
几点值得知道:
|
||||
|
||||
- **URL 形态不用猜。** 厂商对「`/v1` 放 base 还是放 path」没有共识,探测会
|
||||
依次尝试若干候选并返回答对的那个,同时给出归一化后的 `base_url` +
|
||||
`endpoint`(`endpoint` 平时留空,因为 openai adapter 自带 `/chat/completions`)。
|
||||
- **`image_endpoint` 是另一套规则。** 网关的图像默认路径是**绝对**的
|
||||
`/v1/images/generations`,而 chat 默认是相对的。所以 `base_url` 带 `/v1` 时
|
||||
必须显式给 `image_endpoint: /images/generations`,否则图像请求 404。
|
||||
探测会替你填好。
|
||||
- **图像模型只看上游自己声明的 modality**,绝不从模型名猜。`gpt-image-2`
|
||||
这类 id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持
|
||||
图像生成的模型,其后每一层都被它拖垮。
|
||||
- **不会逐模型探活。** 34 个模型的目录跑一轮要花钱且耗时数分钟,`/models`
|
||||
是足够便宜的信号;模型是否真能应答留给正常请求去发现。
|
||||
- **对已存在的源,必须显式 `replace_models: true`** 才会覆盖模型列表。重新
|
||||
探测顺手抹掉为 AUTO 链手工调过的 priority,是最难排查的一类数据丢失。
|
||||
- **`api_key` 留空表示沿用已存值**,不会把凭据清空。
|
||||
|
||||
|
||||
`config.yaml`、`runtime.json`、`master.key`、`adapters/` 均不提交版本库(见
|
||||
`.gitignore`),请随备份带走——`master.key` 丢失后运行时密文无法解密。
|
||||
|
||||
运行文件生成在 `-config` 所在目录:`adapter_dir` / `runtime_file` 自动指向
|
||||
配置文件旁边的 `adapters/` 与 `runtime.json`,从任意工作目录启动都可用。
|
||||
|
||||
运行时文件(`runtime_file`)中的敏感字段自动加密:
|
||||
|
||||
- 加密算法 AES-256-GCM,格式 `enc:v1:<base64>`。
|
||||
- 主密钥来源:环境变量 `MODELROUTER_MASTER_KEY`(64 位 hex);否则读取
|
||||
`runtime_file` 同目录的 `master.key`;都不存在时首次启动自动生成 `master.key`(0600)。
|
||||
- 升级时旧明文文件自动兼容:首次运行正常读取,任何 UI 保存操作触发全文件加密迁移。
|
||||
- 注意:**master.key 丢失后密文无法解密**,请随配置一起备份;切勿提交到版本库。
|
||||
|
||||
## 网关密钥(多密钥)
|
||||
|
||||
网关鉴权采用「多密钥 + 角色 + 模型范围」架构,密钥持久化在 `runtime_file` 的
|
||||
`keys` 字段(加密存储):
|
||||
|
||||
- `gateway_keys` 配置只是**初始 admin 密钥种子**:首次启动迁移为运行时 admin
|
||||
key,之后不再参与鉴权管理。
|
||||
- **重要:首次启动后请在 WebUI「密钥」页更换管理员密钥**——初始密钥明文写在
|
||||
`config.yaml` 里(每次安装随机生成),继续使用存在被盗风险;用新密钥登录后删除初始密钥。
|
||||
- WebUI **密钥页**可创建/删除密钥;每个密钥可指定 `admin`(管理全部)或
|
||||
`user`(仅看自己的 key)角色,并配置**模型范围**(模型 + 源 + token 配额 +
|
||||
重置周期)。
|
||||
- 客户端用任意一个已授权的密钥明文作为 Bearer(`Authorization: Bearer <key>`)。
|
||||
- 删除密钥即从运行时存储移除,立即失效。
|
||||
|
||||
### 密钥用量配额
|
||||
|
||||
**配额按模型单独设置**:每个密钥的 `models[]` 里,每一条模型范围各自带一份
|
||||
token 配额与请求数配额。一个模型用满只影响该模型,同一密钥的其它模型照常工作。
|
||||
|
||||
```yaml
|
||||
keys:
|
||||
- key: sk-gw-<hex>
|
||||
role: user
|
||||
name: agent-alice
|
||||
models:
|
||||
- model: deepseek-v4-flash
|
||||
token_quota: 1000000 # 本周期内该 key 用这个模型的 token 预算
|
||||
req_quota: 20000 # 本周期内的请求次数
|
||||
period: nhour # "" | hour | week | month | nhour
|
||||
hours: 6 # 仅 nhour
|
||||
- model: AUTO
|
||||
token_quota: 5000000 # AUTO 也是一条独立配额
|
||||
period: day # ← 这种写法会被拒绝(词表只有 hour/week/month/nhour)
|
||||
- model: kimi-k3 # 未列配额 = 无限
|
||||
```
|
||||
|
||||
- **没有「整钥总配额」**:这是刻意的设计。整钥总额会让「把 A 模型的额度挪给
|
||||
B 模型」变成一次全局重分配;按模型独立计费则每个模型各自可控,运维可以
|
||||
看出哪个模型吃掉了预算。
|
||||
- `period` 词表:空 = 永不过期(累计总量),`hour` / `week` / `month` = 固定窗口,
|
||||
`nhour` + `hours` = 自定义小时数。**拼错的周期在写入时就被拒**,不会静默变成
|
||||
永不过期。
|
||||
- 配额严格按密钥隔离,且**同一密钥内按模型隔离**:A 密钥用满 `m1` 不会消耗
|
||||
B 密钥的额度,同一密钥的 `m2` 也不受影响。
|
||||
- 配额统计含聊天、流式、生图,跨重启从审计日志回放(保留 40 天,覆盖最长的
|
||||
month 窗口)。
|
||||
- 配额耗尽返回 **429 + `Retry-After`**(`rate_limit_exceeded`),消息里点名是哪个
|
||||
模型用满了,客户端可等窗口重置后自动恢复;模型越权才是 403。
|
||||
**admin 密钥永不受配额限制**(其 scope 上的配额也不强制),避免把管理员
|
||||
锁在门外。
|
||||
- 窗口用量按整点小时分桶统计,实际释放比配置窗口最多晚 1 小时(配额宁可晚释放
|
||||
也不超发)。
|
||||
- `PUT /api/keys/{key}` 提交 `models` 即同时提交它们的配额(配额就是 scope 的一部分,
|
||||
不存在会与模型列表脱节的第二份预算)。显式 `0` = 解除该模型的限制。
|
||||
|
||||
#### 配额拒绝 vs 容量拒绝:两种「拒绝」含义不同
|
||||
|
||||
配额在**入口**检查,不占用任何上游槽位,因此是廉价拒绝;容量不足则要走
|
||||
`busyWait` 有界等待后才返回 503。实测(容量 8、0.6s/请求、100 并发):
|
||||
|
||||
| 情况 | 状态码 | 延迟 | 是否打到上游 |
|
||||
|---|---|---|---|
|
||||
| 配额用尽(`req_quota` 50 → 100 并发) | `429 rate_limit_exceeded` + `Retry-After` | **19–21ms** | 否 |
|
||||
| 容量打满(同一拓扑,未限配额) | `503 upstream_error` | ~2.6s(`busyWait`) | 超出部分否 |
|
||||
|
||||
客户端因此可以区分「等窗口重置」与「等上游腾出容量」:前者只需等待,后者
|
||||
通常该降并发或换源。
|
||||
|
||||
#### 配额的开销
|
||||
|
||||
配额桶按 (密钥, 模型, 整点小时) 分桶保留 40 天,实测(AMD 7840HS):
|
||||
|
||||
- 窗口查询按窗口长度而非保留总量扫描:1h 窗口 49ns、24h 窗口 55ns、
|
||||
30d 窗口 3.9μs(此前全扫保留总量,960 桶时 5.9μs)。
|
||||
- 记录一条请求:283ns、3 分配(与引入配额前相同,分配来自 ring buffer)。
|
||||
- 内存:生产形态(7 密钥 × 8 模型 × 2 源 × 满 40 天 retention)约 **3.7MB**。
|
||||
按源 pin 的 `source::model` 桶**惰性创建**——只有当某条配额真的 pin 了
|
||||
某个源时才维护,否则每条记录多写一份桶,在 20 密钥 × 8 模型 × 3 源下会
|
||||
多占 18MB。
|
||||
代价:配置 pinned 配额之前的历史用量无法事后按源拆分,所以 **pinned 配额的
|
||||
首个窗口可能少算**。
|
||||
|
||||
```bash
|
||||
# 配额耗尽时客户端看到(点名了具体模型)
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
Retry-After: 2100
|
||||
{"error":{"type":"rate_limit_exceeded","message":"token quota exceeded for \"deepseek-v4-flash\" (5000000/5000000)"}}
|
||||
```
|
||||
|
||||
@ -1,142 +0,0 @@
|
||||
# 部署与迁移
|
||||
|
||||
> 本文档面向运维:单机 systemd 部署、发行包安装、目录与权限、从 `llmsproxy`
|
||||
> 改名后的迁移与回滚。
|
||||
> 返回 [README](../README.md)。
|
||||
|
||||
## 1. 单机部署(推荐)
|
||||
|
||||
仓库根目录的 `deploy.sh` 负责:构建 → 预检配置 → 备份 → 原子替换二进制 →
|
||||
重启服务 → 后置校验。它**不**修改源码,也**不**假设全新机器。
|
||||
|
||||
```bash
|
||||
sudo ./deploy.sh # 默认读取仓库内 config.yaml
|
||||
sudo ./deploy.sh --config other.yaml # 投放并部署另一份配置
|
||||
```
|
||||
|
||||
部署目标(改名后):
|
||||
|
||||
> 两种布局并存,区别只在于二进制位置:**包安装**(deb/rpm)放 `/usr/bin`,
|
||||
> unit 自带;**源码部署**(`deploy.sh`)放 `/usr/local/bin`,需要让 unit 的
|
||||
> `ExecStart` 指向它。`deploy.sh` 会校验这一点,写错时直接中止而不是留下
|
||||
> 一个 203/EXEC 的服务。
|
||||
|
||||
| 项 | 路径(源码部署 / 包安装) |
|
||||
|---|---|
|
||||
| 二进制 | `/usr/local/bin/modelrouter` / `/usr/bin/modelrouter` |
|
||||
| 配置 | `/etc/modelrouter/config.yaml` |
|
||||
| 适配器 | `/etc/modelrouter/adapters/` |
|
||||
| 插件(可选) | `/etc/modelrouter/plugins/`(需在配置里设 `plugin_dir`) |
|
||||
| 主密钥 | `/etc/modelrouter/master.key`(0600) |
|
||||
| systemd unit | `/etc/systemd/system/modelrouter.service` |
|
||||
|
||||
`deploy.sh` 会在以下情况**中止**而不是"看起来成功":
|
||||
|
||||
- 部署后二进制起不来、或新配置 `-check` 不通过;
|
||||
- 凭据无法解封(`master.key` 丢失 / 与配置不匹配);
|
||||
- 检测到改名前的 `/etc/llmsproxy`,但新配置尚未建立(否则会部署出一个
|
||||
没有 key、没有源的空实例,所有请求都被拒)。
|
||||
|
||||
## 2. 发行包安装
|
||||
|
||||
三个产物由 `packaging/core-dist.sh` 生成到 `cmd/build/dist/`:
|
||||
|
||||
```text
|
||||
modelrouter_<ver>_amd64.deb
|
||||
modelrouter-<ver>.x86_64.rpm
|
||||
modelrouter-<ver>-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
deb / rpm 安装后:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now modelrouter
|
||||
sudo journalctl -u modelrouter -f
|
||||
```
|
||||
|
||||
tar 包内只有:`modelrouter`、`config.example.yaml`、`modelrouter.service`、
|
||||
`migrate-to-modelrouter.sh`、`adapters/`。
|
||||
|
||||
**发行包不含业务插件**(仓库只保留 `billing` / `usage` 两个与厂商无关的示例)。
|
||||
它们保留在仓库中作为可选扩展,需要时手工放入 `plugin_dir`,网关不会自动安装、
|
||||
覆盖或删除。这是有意的:插件属于运维资产,生命周期不与内核绑定。
|
||||
|
||||
首次启动会在 `/etc/modelrouter/config.yaml` 生成一份**随机 admin key** 的配置,
|
||||
key 打印在启动日志里。发行包不预置任何可用 key(历史上曾因此泄露过一个真实的
|
||||
`sk-gw-local-0001`)。
|
||||
|
||||
## 3. 目录与权限
|
||||
|
||||
- `/etc/modelrouter` 是唯一的读写根(systemd unit 用 `ProtectSystem=strict` +
|
||||
`ReadWritePaths=/etc/modelrouter` 固化这一点)。
|
||||
- `master.key` 必须 0600。当前 unit 以 root 运行:降权到普通用户需要先把
|
||||
`master.key` 交给该用户并统一目录属主,否则服务会"带着明文落盘继续跑",
|
||||
失败只留一行日志——这是独立变更,不要和加固混在一起。
|
||||
- `plugin_dir` 由运维维护;插件自带的状态文件也在其中。
|
||||
|
||||
## 4. 从 llmsproxy 迁移
|
||||
|
||||
二进制、配置目录、服务名、环境变量已统一为 `modelrouter`:
|
||||
|
||||
| 旧 | 新 |
|
||||
|---|---|
|
||||
| `/usr/local/bin/llmsproxy` | `/usr/local/bin/modelrouter` |
|
||||
| `/etc/llmsproxy/` | `/etc/modelrouter/` |
|
||||
| `llmsproxy.service` | `modelrouter.service` |
|
||||
| `LLMSPROXY_PLUGIN_DIR` | `MODELROUTER_PLUGIN_DIR` |
|
||||
| `LLMSPROXY_XDISPLAY_BASE` | `MODELROUTER_XDISPLAY_BASE` |
|
||||
| `LLMS_PROXY_MASTER_KEY` | `MODELROUTER_MASTER_KEY` |
|
||||
|
||||
迁移脚本**默认 dry-run**,复制而非移动,旧目录永不删除:
|
||||
|
||||
```bash
|
||||
sudo ./packaging/migrate-to-modelrouter.sh # 预演(逐条打印将做什么)
|
||||
sudo ./packaging/migrate-to-modelrouter.sh --apply # 执行
|
||||
```
|
||||
|
||||
它做五件事:复制状态目录并改写配置里的旧绝对路径、复核 `master.key` 0600、
|
||||
安装新二进制(旧文件保留)、由旧 unit 生成新 unit(改写 ExecStart /
|
||||
WorkingDirectory / ReadWritePaths)、复制 `/usr/share` 资源;最后跑
|
||||
`modelrouter -check` 并 `systemctl daemon-reload`。
|
||||
|
||||
确认新服务可用后,再手工清理旧布局(脚本不会删):
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now llmsproxy.service
|
||||
sudo rm -f /etc/systemd/system/llmsproxy.service
|
||||
sudo rm -rf /usr/local/bin/llmsproxy /etc/llmsproxy /usr/share/llmsproxy
|
||||
```
|
||||
|
||||
回滚(不需要旧文件被删):
|
||||
|
||||
```bash
|
||||
sudo ./packaging/migrate-to-modelrouter.sh --rollback --apply
|
||||
```
|
||||
|
||||
**读取侧兼容**:旧配置路径与旧环境变量仍被接受,所以"先换二进制、后改 unit"
|
||||
这种顺序不会立刻失效。但写入永远落在新路径,迁移完成后不要两台布局混用。
|
||||
|
||||
## 5. 部署后验证清单
|
||||
|
||||
```bash
|
||||
# 1) 进程与监听
|
||||
systemctl status modelrouter --no-pager
|
||||
ss -ltnp | grep modelrouter
|
||||
|
||||
# 2) 鉴权生效(无 key 必须 401)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/v1/models
|
||||
|
||||
# 3) 带 key 能列出模型
|
||||
curl -s -H "Authorization: Bearer $KEY" http://127.0.0.1:8080/v1/models | head -c 400
|
||||
|
||||
# 4) 真实请求走通(证明 LuaJIT 适配器路径未被打断)
|
||||
curl -s -H "Authorization: Bearer $KEY" \
|
||||
-d '{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}' \
|
||||
http://127.0.0.1:8080/v1/chat/completions | head -c 400
|
||||
|
||||
# 5) 仓库文件与生产文件一致(部署脚本的收尾检查)
|
||||
sha256sum /usr/local/bin/modelrouter build/modelrouter
|
||||
```
|
||||
|
||||
第 4 步失败但第 3 步成功,通常是适配器或上游凭据问题,不是部署问题:
|
||||
查看 `journalctl -u modelrouter -n 100`。
|
||||
@ -1,117 +0,0 @@
|
||||
# 插件可调用内核动作(R4-c)设计
|
||||
|
||||
> 用户决策(2026-10-11):**全开**(13 个内核动作全部可调)+ **设置页可配权限** +
|
||||
> **Go 侧 deadline 兜底**。
|
||||
|
||||
## 1. 要解决的问题
|
||||
|
||||
用户原话:"现在这个接口不全,可以多给一些接口让 lua 调用内核。"
|
||||
|
||||
现状(`__llmsproxy_adapters`)是我手写的**三个闭包**(install/update/remove),
|
||||
只能管适配器。内核还有 13 个公开动作(Reload / ResetHealth / AddSource /
|
||||
RemoveSource / SaveAutoRules / key 增删改 / 列表读取…),插件一个都调不到。
|
||||
|
||||
而"自动孵化 / 自愈 / 联动"这类插件能力(例如:探测到上游全挂 → 触发重探 +
|
||||
清冷却;发现额度变化 → 重排 AUTO 链)恰恰需要它们。
|
||||
|
||||
## 2. 关键认识:Go 调用可以超时,Lua 死循环不能
|
||||
|
||||
这决定了整个设计,也是用户提"阻塞 + Go 兜底"的原因:
|
||||
|
||||
| 形态 | 能否中断 | 处置方式 |
|
||||
|---|---|---|
|
||||
| Lua 里 `while true do end` | **不能**(LuaJIT 限制,已实测三次) | 只能停用插件 |
|
||||
| Lua 里 `__llmsproxy_kernel.call("reload")` | **能** | Go 侧 `select` + deadline,超时把错误返回给 Lua |
|
||||
|
||||
因为 Lua 只是在等 Go 的返回值,**不占用 Lua 栈的执行**。所以给每次内核调用加
|
||||
Go 侧 deadline 是可行的、干净的 —— 不需要"Lua 投递请求、Go 队列执行":那样
|
||||
还要定义"超时后队列里积压的命令怎么办",而 deadline 直接返回 `(nil, err)`,
|
||||
语义闭合。
|
||||
|
||||
## 3. 依赖方向
|
||||
|
||||
`internal/lua` **不能** import `internal/core`(方向是 core → lua)。
|
||||
按既有先例(`SetOutboundHosts`)注入一个**接口**,由 core 实现:
|
||||
|
||||
```go
|
||||
// internal/lua/kernel_api.go
|
||||
type KernelAction struct {
|
||||
Name string // "reload" / "reset_health" / ...
|
||||
Title string // 设置页展示用
|
||||
Description string
|
||||
Danger string // ""=只读, "write"=改配置, "danger"=破坏性
|
||||
Params []string // 参数名(设置页/文档用)
|
||||
}
|
||||
|
||||
type KernelAPI interface {
|
||||
// Actions 列出可调用动作(设置页据此渲染权限开关)
|
||||
Actions() []KernelAction
|
||||
// Invoke 执行一个动作,args 为已解码参数。**必须**尊重 ctx 的 deadline。
|
||||
Invoke(ctx context.Context, name string, args map[string]any) (any, error)
|
||||
}
|
||||
```
|
||||
|
||||
core 侧实现 `Invoke` 时用**现有的 Core 方法**,本身不持 `p.mu`,因此不会与
|
||||
插件锁成环(已核实:所有 `ps.mu` → `p.mu` 都是先释放 `ps.mu`)。
|
||||
|
||||
## 4. 权限模型
|
||||
|
||||
复用 `visibilityOverrides` 的**同一套持久化机制**(`.access.json`),
|
||||
但用独立的键空间:`kernel:<plugin>` → 逗号分隔的动作名或 `*`。
|
||||
|
||||
- **默认拒绝**:插件不显式获得授权时,`call` 返回 `(nil, "not permitted")`。
|
||||
理由:全开是"能力存在",不是"默认授予"。一个插件能删密钥这件事必须由
|
||||
运维在设置页点过确认。
|
||||
- **`danger` 类必须显式逐个授权**,不接受 `*`。这一条是防止运维图省事写
|
||||
通配符,把"删密钥"和"读状态"等同起来。
|
||||
- 插件可以在自己源码里**声明**它想要的动作(`plugin.kernel = {...}`),
|
||||
设置页把它显示为"申请",运维批准后才生效。声明不等于授权。
|
||||
|
||||
## 5. 动作清单(13 个 + 适配器 3 个)
|
||||
|
||||
| 动作 | 危险级 | 对应 Core 方法 | 超时 |
|
||||
|---|---|---|---|
|
||||
| `status` | 只读 | 读 AutoSlotStates/Registry | 2s |
|
||||
| `list_sources` | 只读 | Sources() | 2s |
|
||||
| `list_models` | 只读 | Registry().ModelList() | 2s |
|
||||
| `list_adapters` | 只读 | ListAdapters() | 2s |
|
||||
| `adapter_owner` | 只读 | AdapterOwnerOf | 2s |
|
||||
| `reload` | write | Reload() | 30s |
|
||||
| `reset_health` | write | ResetHealth() | 5s |
|
||||
| `probe_all` | write | Registry().ProbeAll(ctx) | 60s |
|
||||
| `save_auto_rules` | write | SaveAutoRules() | 10s |
|
||||
| `save_key_auto` | write | SaveKeyAuto() | 10s |
|
||||
| `add_source` | write | AddSource() | 30s |
|
||||
| `remove_source` | danger | RemoveSource() | 30s |
|
||||
| `create_key` | write | CreateKey() | 10s |
|
||||
| `update_key` | write | UpdateKey() | 10s |
|
||||
| `delete_key` | danger | DeleteKey() | 10s |
|
||||
| `install_adapter` | write | UploadAdapter() | 10s |
|
||||
| `remove_adapter` | danger | RemoveAdapter() | 30s |
|
||||
| `set_plugin_enabled` | danger | SetEnabled() | 5s |
|
||||
|
||||
**绝不开放**:`ListKeys()` / `FindKey()` —— 返回**明文密钥**。给插件读密钥等于
|
||||
把网关的凭据面交给第三方 Lua。要用 key 相关动作就只用带掩码的形态。
|
||||
|
||||
## 6. Lua 调用面
|
||||
|
||||
```lua
|
||||
-- 探测:拿到可用动作与自己的授权情况
|
||||
local acts = __llmsproxy_kernel.actions()
|
||||
-- 调用:统一入口,返回 (result, err)
|
||||
local ok, err = __llmsproxy_kernel.call("reset_health", {})
|
||||
local st, err = __llmsproxy_kernel.call("status", {})
|
||||
```
|
||||
|
||||
统一入口(而不是每个动作一个全局函数)的理由:动作集合将来会变,而
|
||||
`__llmsproxy_kernel` 的签名不变 —— 否则每加一个动作就要动
|
||||
`bootPluginState`,而那里正是历史上出过 cgo 崩溃的地方。
|
||||
|
||||
## 7. 实施顺序
|
||||
|
||||
1. `internal/lua/kernel_api.go`:接口 + 动作元数据 + 权限检查 + deadline 包装
|
||||
2. `internal/lua/plugin_kernel_bridge.go`:Lua 绑定(`actions` / `call`)
|
||||
3. `internal/core/kernel_actions.go`:实现 `KernelAPI`(core 侧)
|
||||
4. 权限持久化:复用 `.access.json`,新键空间
|
||||
5. 设置页:插件行加"内核动作"列 + 授权编辑
|
||||
6. 测试:Go 侧全动作、Lua 侧调用、权限拒绝、deadline 生效、危险动作需显式授权
|
||||
@ -1,34 +0,0 @@
|
||||
# 开发与目录结构
|
||||
|
||||
> 本文档由 README 拆分而来:目录职责、开发协作、测试。
|
||||
> 返回 [README](../README.md)。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
cmd/modelrouter # 入口(纯 Go 服务端)
|
||||
cmd/gui # Electron 桌面 GUI(内嵌核心 + 托盘 + 打包)
|
||||
internal/config # YAML 配置加载/校验
|
||||
internal/lua # LuaJIT VM + worker 池 + AdapterCache + 内置适配器 (embed)
|
||||
internal/provider # Provider(HTTP) + Registry(路由)
|
||||
internal/gateway # OpenAI 兼容 HTTP 服务 + 鉴权 + SDK/流式 + WebUI
|
||||
internal/types # 统一格式 & OpenAI wire 类型
|
||||
```
|
||||
|
||||
## 开发协作
|
||||
|
||||
- **Git 分支工作流**:主分支 / 特性分支 / 发布分支的规范见
|
||||
**[docs/git-workflow.md](docs/git-workflow.md)**([English](docs/git-workflow-en.md))。
|
||||
主线:`main` 永可部署 → 特性合入 `main` → 切 `release/vX.Y.Z` 打 tag 发布 →
|
||||
hotfix 提交发布分支并 cherry-pick 回 `main`。
|
||||
- **Lua 适配器协议**:见 **[docs/lua-adapters.md](docs/lua-adapters.md)**
|
||||
([English](docs/lua-adapters-en.md))。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test -tags luajit ./...
|
||||
```
|
||||
|
||||
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
|
||||
模型路由、多模态透传与 thinking 预算。
|
||||
@ -1,48 +0,0 @@
|
||||
# 实现与完整特性
|
||||
|
||||
> 本文档由 README 拆分而来:架构来源与完整特性清单(README 只保留摘要)。
|
||||
> 返回 [README](../README.md)。
|
||||
|
||||
## 实现
|
||||
|
||||
统一 OpenAI 兼容网关:把多个上游 LLM 源(DeepSeek、Qijiar、OpenAI、Anthropic、Gemini、
|
||||
Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换,对内网暴露
|
||||
一个标准 `OpenAI Chat Completions` 接口(`/v1/chat/completions` + `/v1/models`),
|
||||
支持单次结算、SSE 流式、**AUTO 模型路由**与**多模态**透传。
|
||||
|
||||
从上游多源 LLM 适配层
|
||||
(`internal/agent/api/provider.go` + `internal/lua/adapters/*`)抽离而来并独立演进。
|
||||
|
||||
## 特性
|
||||
|
||||
- **多源**:一个进程内配置任意多个上游源,按请求的 `model` 自动路由。
|
||||
- **AUTO 模式**:`default_model: AUTO` 时按优先级页保存的 AUTO 链档位逐档调度并发请求。
|
||||
- **统一输出**:所有源对外都是 OpenAI 格式(含 `reasoning_content`、`tool_calls`、`usage`)。
|
||||
- **生图**:`POST /v1/images/generations`,`kind: image` 的模型独立路由。
|
||||
- **多模态**:`content` 数组(`image_url` 等)在多源间无损透传,Anthropic/Gemini/Ollama 自动转换。
|
||||
- **LuaJIT VM**:基于 golua 绑定的 LuaJIT,每个适配器独立 VM + worker 池,安全并发。
|
||||
池是 **弹性** 的:`Σ max_concurrent` 只是上限而非预分配,状态按需创建、空闲时自动回收,
|
||||
因此空载网关几乎不持有 Lua 状态(启动 0 个)。
|
||||
- 扩容步长由适配器最大并发决定:`clamp(ceil(max/8), 1, 8)`,且仅在**真实并发争用**
|
||||
(所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。
|
||||
- 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到
|
||||
常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。
|
||||
- **thinking 预算**:请求里的推理开关/预算归一为中立的 `thinking:{enabled,budget_tokens}`,
|
||||
适配器映射到各自协议(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig`、
|
||||
DeepSeek `thinking.type="disabled"`)。
|
||||
- **Lua 适配协议**:每个源挂一个 `.lua` 适配器,完成 `transform_request` /
|
||||
`transform_response` / `transform_stream_chunk` 双向转换,协议差异全在 Lua 层。
|
||||
- **签名 / 请求头钩子**:适配器可定义 `build_headers(meta)`,在 Go 发 HTTP 前
|
||||
动态注入/签名请求头——用于云端 API 校验调用方 app(如 KimiCode 只放行特定
|
||||
agent)。提供 `hmac_sha256_hex` / `sha256_hex` / `base64_encode` 等签名辅助。
|
||||
- **WebUI**:内置管理界面,可在线查看/新增/编辑上游源与模型、配置 AUTO 优先级链、
|
||||
管理密钥模型范围,写入运行时文件持久化。
|
||||
- **密钥加密存储**:运行时文件中的上游 `api_key`、自定义请求头值、网关 key 均以
|
||||
AES-256-GCM 加密落盘(`master.key` 独立 0600,或 `MODELROUTER_MASTER_KEY`)。
|
||||
- **实时源探测**:状态页探测各源可达性(`GET {base}/models`,失败回退最小请求),
|
||||
不污染正常调度的退避状态,错误信息在 UI 可悬停查看。
|
||||
- **鉴权**:网关自身用 `gateway_keys` 校验客户端 Bearer key;与上游各自的 key 相互独立。
|
||||
- **流式**:SSE `chat.completion.chunk`,含角色首包与 `[DONE]` 收尾。
|
||||
- **全链路审计**:HTTP 访问日志、登录/配置变更审计、请求记录 CSV 导出、密钥用量统计 CSV 导出
|
||||
- **AES-256-GCM 加密存储**:运行时文件敏感字段加密落盘,master.key 0600 权限,支持环境变量注入主密钥
|
||||
|
||||
@ -15,7 +15,7 @@ deployable.
|
||||
|---|---|---|---|
|
||||
| Main | `main` | permanent | Only long-lived branch. Always deployable. Accumulates the next version. |
|
||||
| Feature | `feature/<desc>` | short (dev → merge → delete) | New features / ordinary fixes. Born from `main`, merged back into `main`. |
|
||||
| Release | `release/vX.Y.x` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. |
|
||||
| Release | `release/vX.Y.Z` | one version cycle | Cut from `main`, tagged for release. Version-specific hotfixes land here. |
|
||||
|
||||
## Change flow (important)
|
||||
|
||||
@ -26,21 +26,15 @@ deployable.
|
||||
│ │
|
||||
│ cut │ cut
|
||||
▼ ▼
|
||||
release/v1.6.x release/v1.7.x
|
||||
release/v1.4.2 release/v1.4.3
|
||||
│ │
|
||||
tag: v1.6.0 tag: v1.7.6
|
||||
tag: v1.4.2 tag: v1.4.3
|
||||
│ │
|
||||
hotfix ◄─────┘ hotfix ◄─────┘
|
||||
│ │
|
||||
└── cherry-pick back ──────────┘
|
||||
```
|
||||
|
||||
The patch position in the branch name is a literal `x`, while the tag carries
|
||||
the concrete version: one `release/v1.7.x` can hold tags v1.7.0 … v1.7.6.
|
||||
Spanning several patches on a single minor branch is deliberate — patches are
|
||||
revisions of the same feature batch, hotfixes land on one branch, and back-port
|
||||
to main never has to resolve dependencies between several release branches.
|
||||
|
||||
### Key rules
|
||||
|
||||
1. **main is always deployable**: never leave half-done work on `main`.
|
||||
@ -49,9 +43,9 @@ to main never has to resolve dependencies between several release branches.
|
||||
then `git merge --no-ff feature/xxx` (or squash) when done.
|
||||
3. **Release = cut a release branch from main + tag**:
|
||||
```bash
|
||||
git checkout -b release/v1.7.x main
|
||||
git tag -a v1.7.0 -m "ModelRouter v1.7.0"
|
||||
git push origin release/v1.7.x v1.7.0
|
||||
git checkout -b release/v1.4.2 main
|
||||
git tag -a v1.4.2 -m "ModelRouter v1.4.2"
|
||||
git push origin release/v1.4.2 v1.4.2
|
||||
```
|
||||
Build installers and upload the GitCode Release from this tag so the
|
||||
published state is exactly reproducible.
|
||||
@ -60,7 +54,7 @@ to main never has to resolve dependencies between several release branches.
|
||||
an already-released branch (unless you deliberately ship a minor revision).
|
||||
5. **Hotfixes MUST flow back to main**:
|
||||
```bash
|
||||
git checkout release/v1.7.x # fix in the release branch
|
||||
git checkout release/v1.4.2 # fix in the release branch
|
||||
git commit -m "fix: ..."
|
||||
git checkout main
|
||||
git cherry-pick <hotfix-commit> # and into main
|
||||
@ -73,23 +67,12 @@ to main never has to resolve dependencies between several release branches.
|
||||
When the next version ships, the previous release branch retires:
|
||||
|
||||
- **Default: delete the remote release branch**
|
||||
(`git push origin :release/v1.7.x`). All hotfixes were already
|
||||
(`git push origin :release/v1.4.2`). All hotfixes were already
|
||||
cherry-picked into main, so main contains everything; no merge needed.
|
||||
- **Long-term maintenance** (e.g. an enterprise client pinned to an old
|
||||
version): keep the branch, accept only security fixes, keep the
|
||||
commit-then-cherry-pick loop.
|
||||
|
||||
> **Where practice diverged from this section (checked 2026-10-01)**:
|
||||
> `release/v1.4.x` and `release/v1.5.x` still exist locally and on the remote,
|
||||
> so "retire the previous branch when the next version ships" was never
|
||||
> carried out. Keeping them is harmless (hotfixes were back-ported), but it
|
||||
> contradicts the rule above and makes a reader wonder whether they should be
|
||||
> there at all. **Feature branches, by contrast, are cleaned up**:
|
||||
> `feature/key-quota-control`, `feature/toolcall-id-sanitize`,
|
||||
> `feature/anthropic-usage-cache` and `feature/agentrouter-id-sanitize` were
|
||||
> deleted on 2026-10-01 after confirming with a per-commit `git patch-id`
|
||||
> comparison that their work had already landed in main.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- **Never rebase main**: main's history stays append-only; anyone pulling gets
|
||||
@ -134,7 +117,7 @@ git push origin vX.Y.Z
|
||||
make core-dist VERSION=X.Y.Z
|
||||
|
||||
# 5. create GitCode Release + upload
|
||||
# https://atomgit.com/HeNanMU-EEA/ModelRouter/releases
|
||||
# https://gitcode.com/JianFeeeee/ModelRouter/releases
|
||||
# upload MUST use PUT --http1.1, otherwise the OBS callback fails and the
|
||||
# file never lands in the release; replacing artifacts = delete the tag
|
||||
# (release disappears with it) then recreate.
|
||||
@ -151,8 +134,8 @@ pain points:
|
||||
2. No feature branches meant two independent efforts could not proceed in
|
||||
parallel without colliding.
|
||||
|
||||
With release branches: the published state = `release/vX.Y.x` branch +
|
||||
the concrete `vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing
|
||||
spot; main stays "latest + all fixes + deployable".
|
||||
With release branches: the published state = `release/vX.Y.Z` branch +
|
||||
`vX.Y.Z` tag, exactly reproducible; hotfixes have a clear landing spot; main
|
||||
stays "latest + all fixes + deployable".
|
||||
|
||||
> 中文版见 [docs/git-workflow.md](git-workflow.md)。
|
||||
@ -13,7 +13,7 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|
||||
|---|---|---|---|
|
||||
| 主分支 | `main` | 永久 | 唯一长命分支。永远可部署。积攒下一个版本的功能。 |
|
||||
| 特性分支 | `feature/<描述>` | 短命(开发→合并即删) | 新特性 / 一般 bug 修复。从 `main` 开出,完成后合回 `main`。 |
|
||||
| 发布分支 | `release/vX.Y.x` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 |
|
||||
| 发布分支 | `release/vX.Y.Z` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 |
|
||||
|
||||
## 变更流向(重要)
|
||||
|
||||
@ -24,20 +24,15 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|
||||
│ │
|
||||
│ 切出 │ 切出
|
||||
▼ ▼
|
||||
release/v1.6.x release/v1.7.x
|
||||
release/v1.4.2 release/v1.4.3
|
||||
│ │
|
||||
tag: v1.6.0 tag: v1.7.6
|
||||
tag: v1.4.2 tag: v1.4.3
|
||||
│ │
|
||||
hotfix ◄─────┘ hotfix ◄─────┘
|
||||
│ │
|
||||
└── cherry-pick 回 main ───────┘
|
||||
```
|
||||
|
||||
分支名里 patch 位是**字面的 x**,而 tag 打具体版本号:`release/v1.7.x` 这一条
|
||||
发布分支上的 tag 可以有 v1.7.0 … v1.7.6 多个。一条 minor 分支跨多个 patch 是
|
||||
刻意的:patch 是同一批功能的不同修订,hotfix 落在同一条分支上,回流 main 时
|
||||
也不必处理多条 release 分支之间的依赖。
|
||||
|
||||
### 关键规则
|
||||
|
||||
1. **main 永远可部署**:不在 main 上留半成品。任何未完成的工作必须在特性分支上。
|
||||
@ -45,16 +40,16 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|
||||
开发完 `git merge --no-ff feature/xxx` 或 squash 合回。
|
||||
3. **发布 = 从 main 切 release 分支 + 打 tag**:
|
||||
```bash
|
||||
git checkout -b release/v1.7.x main
|
||||
git tag -a v1.7.0 -m "ModelRouter v1.7.0"
|
||||
git push origin release/v1.7.x v1.7.0
|
||||
git checkout -b release/v1.4.2 main
|
||||
git tag -a v1.4.2 -m "ModelRouter v1.4.2"
|
||||
git push origin release/v1.4.2 v1.4.2
|
||||
```
|
||||
构建安装包、上传 GitCode Release 都基于这个 tag,保证可精确回溯发布态。
|
||||
4. **版本生命周期内只收该版本的 hotfix**:新特性一律并入 `main` 等下一个版本,
|
||||
绝不塞进已发布的 release 分支(除非主动选择在该版本内发次要版)。
|
||||
5. **hotfix 必须回流 main**:
|
||||
```bash
|
||||
git checkout release/v1.7.x # 在发布分支提交修复
|
||||
git checkout release/v1.4.2 # 在发布分支提交修复
|
||||
git commit -m "fix: ..."
|
||||
git checkout main
|
||||
git cherry-pick <hotfix-commit> # 回主分支
|
||||
@ -66,20 +61,11 @@ ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长
|
||||
|
||||
下一个版本发布时,上一个 release 分支退役:
|
||||
|
||||
- **默认:直接删除远端 release 分支**(`git push origin :release/v1.7.x`)。
|
||||
- **默认:直接删除远端 release 分支**(`git push origin :release/v1.4.2`)。
|
||||
因为 hotfix 都已逐个 cherry-pick 回 main,main 已包含全部修复,无需再合并。
|
||||
- **如需要长期维护旧版**(例如企业大客户卡在旧版本):保留分支,仅 stopship 接受
|
||||
该版本的安全修复,继续走「提交 + cherry-pick 回 main」循环。
|
||||
|
||||
> **实践与本节的历史出入(2026-10-01 核对)**:`release/v1.4.x` 与 `release/v1.5.x`
|
||||
> 至今仍在本地与远端,说明"下一个版本发布就删上一个分支"实际没有执行。
|
||||
> 保留无害(hotfix 已回流),但它与上面写的规则不一致,读文档的人会以为
|
||||
> 这些分支不该存在。**特性分支则确实在清理**:`feature/key-quota-control`、
|
||||
> `feature/toolcall-id-sanitize`、`feature/anthropic-usage-cache`、
|
||||
> `feature/agentrouter-id-sanitize` 四个分支在 2026-10-01 删除——它们的工作
|
||||
> 早已全部进入 main(逐提交用 `git patch-id` 比对确认),留着只是给下个版本
|
||||
> 制造 cherry-pick/merge 陷阱。
|
||||
|
||||
## 明确不做的事
|
||||
|
||||
- **不 rebase main**:`main` 的历史保持追加式,任何人拉取后 `git pull` 都得到直接可用的历史。
|
||||
@ -124,7 +110,7 @@ git push origin vX.Y.Z
|
||||
make core-dist VERSION=X.Y.Z
|
||||
|
||||
# 5. 创建 GitCode Release + 上传安装包
|
||||
# 见 https://atomgit.com/HeNanMU-EEA/ModelRouter/releases
|
||||
# 见 https://gitcode.com/JianFeeeee/ModelRouter/releases
|
||||
# 上传注意:必须 PUT --http1.1,否则 OBS 回调失败文件不进列表;
|
||||
# 替换旧版附件 = 删 tag(release 随 tag 一起消失)后重建。
|
||||
```
|
||||
|
||||
86
docs/gui.md
86
docs/gui.md
@ -1,86 +0,0 @@
|
||||
# 桌面 GUI(Electron)
|
||||
|
||||
> 本文档由 README 拆分而来:Headless 与 Desktop 两种产物、构建与运行。
|
||||
> 返回 [README](../README.md)。
|
||||
|
||||
## 桌面 GUI(Electron)
|
||||
|
||||
可选独立产物,面向内网桌面端用户:**内嵌 ModelRouter 核心**(桌面壳形态),
|
||||
一键启停、系统托盘常驻、开机自启、静默启动,内容区一比一内嵌完整 WebUI
|
||||
(状态/对话/密钥/优先级/源/适配器 六个页面,免登录)。服务器用户继续使用纯 Go 二进制。
|
||||
|
||||
### 两种安装包:Headless 与 Desktop
|
||||
|
||||
发布到 Release 的安装包分为两类,面向不同用户:
|
||||
|
||||
- **Headless(服务器版)**:纯核心二进制(无 Electron),配置驱动,适合服务器、
|
||||
容器、systemd 等无人值守场景。发布包含 `deb` + `rpm`(含 systemd unit、内存调优
|
||||
与示例配置)与 `tar.gz`(二进制 + `config.example.yaml` + `adapters/` + README),
|
||||
Windows 为 `.zip`。
|
||||
- **Desktop(桌面版)**:Electron GUI 安装包,内嵌核心,适合个人桌面日常使用。
|
||||
|
||||
### 选择 GUI 还是纯后端
|
||||
|
||||
| 场景 | 推荐 | 理由 |
|
||||
| ------ | ------ | ------ |
|
||||
| 服务器 / 内网网关 / 无人值守常驻 | **Headless**(单二进制) | 极轻量(二进制 ~8MB;RSS 随源数量,单源 ~10MB、16 源 ~32–35MB,见[内存占用](#内存占用实测与调优)),零依赖单进程,直接跑在 systemd/任意容器里,远程管理 |
|
||||
| 个人桌面日常使用 / 多设备内网共享 | **Desktop**(GUI) | 免登录内嵌 WebUI、系统托盘一键启停、开机自启、静默后台,适合不懂命令行的使用者 |
|
||||
| Windows 桌面 | **Desktop** | 纯后端在 Windows 上需自行注册服务,GUI 提供原生托盘/自启体验 |
|
||||
| CI 一键出三平台安装包 | **Desktop 打包脚本** | `make gui-*` 系列出 deb/AppImage/NSIS,可进发行流水线 |
|
||||
|
||||
两者完全同源:GUI 内嵌的就是纯后端的同一份 `modelrouter` 核心(LuaJIT 版),
|
||||
配置/适配器格式完全一致,可随时互换。
|
||||
|
||||
### 构建与运行
|
||||
|
||||
```bash
|
||||
cd cmd/gui
|
||||
npm install
|
||||
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/modelrouter)
|
||||
make gui-deb # 本机构建 deb(分享给其它 Linux 用户)
|
||||
make gui-dist # 构建 deb + rpm + AppImage(linux,rpm 需系统 rpmbuild)
|
||||
make core-dist # 构建核心 deb + rpm + tar.gz(服务器版,需 nfpm)
|
||||
make gui-win # 构建 Windows nsis 安装包(本机 mingw + wine)
|
||||
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,含 wine)
|
||||
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
|
||||
```
|
||||
|
||||
产物在 `cmd/build/gui-dist/`:`ModelRouter-<ver>.AppImage`、
|
||||
`modelrouter-gui_<ver>_amd64.deb`、`ModelRouter Setup <ver>.exe`(win);
|
||||
核心包在 `cmd/build/dist/`:`modelrouter_<ver>_amd64.deb`、`modelrouter-<ver>.x86_64.rpm`。
|
||||
rpm 需系统 `rpmbuild`。每次打包后都会跑 `packaging/verify-dist.sh` 产物体检
|
||||
(大小下限门禁——1.3.0 曾因宿主 wine 损坏产出 264KB 的残缺 exe 却没有被拦住)。
|
||||
发布时会把产物重命名为 `ModelRouter-Desktop-*`(GUI)与 `ModelRouter-Headless-*`(纯后端)
|
||||
两类上传到 GitCode Release。
|
||||
|
||||
#### Windows 打包(docker 固化方案,推荐)
|
||||
|
||||
`make gui-win-docker` 一步完成:交叉编译核心 + NSIS 打包**全在 docker 内**,宿主机
|
||||
**无需 mingw、无需 wine、无需 node**。镜像内置 wine(含 i386 运行时),修复了
|
||||
此前宿主 wine 损坏(缺 syswow64 → `c0000135`)导致产出 264KB 残缺 exe 的问题。
|
||||
|
||||
- 构建镜像:`cmd/gui/docker/win-builder/Dockerfile`(`golang:1.25` + mingw-w64 +
|
||||
node + wine32/wine64 + 预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。
|
||||
LuaJIT 源码取自国内镜像,上游兜底;Go 依赖走 `https://goproxy.cn`。
|
||||
- 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译 + electron-builder
|
||||
NSIS 全部在容器内执行(`USE_SYSTEM_WINE=true` 走镜像内的 wine);electron-builder
|
||||
的 ~400MB 工具链缓存挂到 docker volume,二次构建增量复用。
|
||||
- 首次运行自动构建 `modelrouter/win-builder` 镜像(约 5–10 分钟);已存在则直接复用。
|
||||
- 打包完成后自动跑 `packaging/verify-dist.sh` 门禁:exe 低于 5MB / deb·rpm·7z 低于
|
||||
10MB 即失败——残缺安装包不可能再溜进 Release。
|
||||
|
||||
```bash
|
||||
# 全 Windows 发布(核心重编 + NSIS):
|
||||
make gui-win-docker
|
||||
```
|
||||
|
||||
### 特性
|
||||
|
||||
- **内嵌核心**:自动拉起捆绑的 `modelrouter`(LuaJIT 版),配置/密钥/adapter 存于
|
||||
用户数据目录 `<userData>/profile/`,首次运行自动生成随机 admin key 写入 `keys`
|
||||
(非 seed)并自注入,WebUI 免登录、不会提示"更换初始密钥"。端口可在设置中更改(默认 8787)。
|
||||
- **系统托盘**:内核状态点、开机自启/静默启动开关、启停/重启内核、退出。
|
||||
- **静默启动**:`--silent` 参数或设置项,启动仅驻托盘不弹窗;首次运行始终显示窗口。
|
||||
- **开机自启**:Windows/macOS 用 `setLoginItemSettings`;Linux 写
|
||||
`~/.config/autostart/modelrouter-gui.desktop`,静默开启时自动附加 `--silent`。
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
5. [内置辅助函数](#5-内置辅助函数)
|
||||
6. [meta 约定](#6-meta-约定)
|
||||
7. [一个完整的最小适配器](#7-一个完整的最小适配器)
|
||||
8. [多模态与 thinking 预算](#8-多模态与-thinking-预算)
|
||||
8. [多模态与 disable_thinking](#8-多模态与-disable_thinking)
|
||||
|
||||
---
|
||||
|
||||
@ -73,9 +73,8 @@ end
|
||||
|
||||
约定:
|
||||
- 返回的字符串将被原样 POST 到 `base_url + (source.endpoint or adapter.endpoint)`。
|
||||
- 契约 v2 起,内核**不再向适配器泄漏任何私有字段**:请求体里不会出现
|
||||
`disable_thinking` / `extra_body` 这类键。推理开关与预算用中立的 `thinking`
|
||||
字段表达(见下),适配器按目标协议各自映射。
|
||||
- 请求体中包含的 `disable_thinking`、`extra_body` 等网关内部字段,转换时应自行清理
|
||||
(`openai.lua` 即删掉这两个字段)。
|
||||
|
||||
### `transform_response(raw_body) -> string`
|
||||
|
||||
@ -177,81 +176,6 @@ end
|
||||
|
||||
返回非 table 会报错;返回 `{}` 表示无自定义头(不会回退静态头)。
|
||||
|
||||
### 出向序列化钩子(契约 v2)
|
||||
|
||||
默认情况下,网关把适配器产出的**统一格式**按 OpenAI 线格式序列化给下游(这是内核
|
||||
保留的兑底,R5)。当上游协议不是 OpenAI(例如 Anthropic Messages API)时,适配器
|
||||
可以接管出向序列化,自己决定写给客户端的字节。
|
||||
|
||||
五个钩子都是可选的,**全不声明就沿用 OpenAI 兑底**。一旦声明某个钩子,它就完全
|
||||
接管那一段输出——内核不会再往里混合 OpenAI 字段(不是“叠加”,是“取代”)。
|
||||
|
||||
| 钩子 | 何时被调用 | 返回值 |
|
||||
|------|-----------|--------|
|
||||
| `serialize_response(payload)` | 非流式响应 | 写给客户端的响应体字符串 |
|
||||
| `serialize_open_preamble(payload)` | 流式开始(发第一个 chunk 前) | 要发的字节(可为 `""`) |
|
||||
| `serialize_chunk(delta)` | 流式每个增量 | 要发的字节(`""` 表示跳过) |
|
||||
| `serialize_close(payload)` | 流式结束 | 收尾字节(如 SSE `message_stop`) |
|
||||
| `serialize_done_marker(payload)` | 流式结束处的 `[DONE]` 标记 | 要写的字节;不声明则用 OpenAI 的 `data: [DONE]` |
|
||||
|
||||
`payload` 是统一格式的结果表(见 `internal/types/maxwire.go` 的 `UnifiedResult` /
|
||||
`UnifiedDelta`),字段名中立,不依赖具体上游协议。
|
||||
|
||||
```lua
|
||||
-- anthropic.lua 的简化形式:把统一结果写成 Anthropic Messages 形状
|
||||
function adapter.serialize_response(payload)
|
||||
return json.encode({
|
||||
id = payload.id,
|
||||
type = "message",
|
||||
role = "assistant",
|
||||
content = { { type = "text", text = payload.content or "" } },
|
||||
stop_reason = payload.finish_reason == "stop" and "end_turn" or nil,
|
||||
usage = { input_tokens = payload.prompt_tokens, output_tokens = payload.completion_tokens },
|
||||
})
|
||||
end
|
||||
```
|
||||
|
||||
注意:钩子返回的是**完整要发送的字节**(包括必要时的 `event:` / `data:` 前缀与
|
||||
空行),因为只有适配器知道目标协议的 SSE 细节。
|
||||
|
||||
### 入向让出钩子(契约 v2,`serialize_*` 的对偶)
|
||||
|
||||
出向是「统一格式 → 客户端协议」,入向是它的镜像:「客户端协议 → 统一格式」。
|
||||
内核为对外端点(如 `POST /v1/messages`)**保留一份内置解析作为兜底(R5)**,
|
||||
但适配器可以**声明自己服务该端点**并接管解析——那时内核主动让出,客户端协议的
|
||||
迭代就完全落在适配器里,不再需要动内核。
|
||||
|
||||
声明分两步:
|
||||
|
||||
1. `adapter.ingress = "/v1/messages"` —— 声明**认领**哪条对外入站路径。
|
||||
同一条路径被多个适配器认领是配置错误;内核按适配器名字典序确定性选择。
|
||||
2. `deserialize_request(raw_body) -> unified_json` —— 把客户端原始 body 解析成
|
||||
统一请求(`UnifiedRequest` 的 JSON),后续调度与 `/v1/chat/completions` 完全一致。
|
||||
|
||||
规则:
|
||||
|
||||
- 只有声明了该端点且实现了 `deserialize_request` 的适配器会让内核让出。
|
||||
- 没有适配器认领时,内核内置解析接管(未配置的部署仍能工作)。
|
||||
- 认领了但钩子**执行失败**是硬错误(400),不会静默回落到另一种协议解析——
|
||||
客户端说的正是这个适配器声称支持的协议,悄悄换解析器只会错误调度请求。
|
||||
|
||||
```lua
|
||||
-- anthropic.lua 的简化形式:把 Anthropic Messages 请求解析成统一格式
|
||||
adapter.ingress = "/v1/messages"
|
||||
function adapter.deserialize_request(raw_body)
|
||||
local req = json.decode(raw_body)
|
||||
local msgs = {}
|
||||
for _, m in ipairs(req.messages or {}) do
|
||||
table.insert(msgs, { role = m.role, content = m.content })
|
||||
end
|
||||
return json.encode({
|
||||
model = req.model, messages = msgs, max_tokens = req.max_tokens,
|
||||
system = type(req.system) == "string" and req.system or "",
|
||||
stream = req.stream == true,
|
||||
})
|
||||
end
|
||||
```
|
||||
|
||||
## 5. 内置辅助函数
|
||||
|
||||
所有适配器共享的全局函数(由 Go 注入):
|
||||
@ -295,13 +219,8 @@ adapter.headers = { ["X-Tenant"] = "prod" }
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- 契约 v2:内核不再向适配器泄漏私有字段,`thinking` 是中立语义字段。
|
||||
-- 这里把中立的 thinking 映射成目标协议需要的形状(示例:OpenAI 家族用
|
||||
-- reasoning_effort)。不关心推理的源可以直接忽略它。
|
||||
if req.thinking ~= nil then
|
||||
if req.thinking.enabled == false then req.reasoning_effort = "none" end
|
||||
req.thinking = nil
|
||||
end
|
||||
req.disable_thinking = nil
|
||||
req.extra_body = nil
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
@ -349,21 +268,11 @@ sources:
|
||||
kind: chat
|
||||
```
|
||||
|
||||
## 8. 多模态与 thinking 预算
|
||||
## 8. 多模态与 disable_thinking
|
||||
|
||||
- **多模态**:请求的 `content` 若是数组(`[{type:"text"...},{type:"image_url"...}]`),Go 层
|
||||
原样透传给适配器。协议不支持的源(Anthropic/Gemini/Ollama)需在
|
||||
`transform_request` 内转换;内置 `anthropic.lua`/`gemini.lua`/`ollama.lua` 已实现。
|
||||
- **thinking**:客户端请求里的推理开关/预算被内核归一成中立的 `thinking` 字段:
|
||||
|
||||
```json
|
||||
{"thinking": {"enabled": true, "budget_tokens": 4096}}
|
||||
```
|
||||
|
||||
`enabled=false` 表示关闭推理,`budget_tokens` 可选。适配器自行映射到目标协议
|
||||
(内置实现:Anthropic → `thinking.budget_tokens`、Gemini →
|
||||
`generationConfig.thinkingConfig.thinkingBudget`、DeepSeek →
|
||||
`thinking.type = "disabled"`)。不支持的源直接忽略该字段。
|
||||
|
||||
契约 v1 的内核私有字段 `disable_thinking` 已被取代——它曾是内核细节泄漏进
|
||||
适配器请求体的典型例子(迫使 10 个适配器各写一行 `req.disable_thinking = nil`)。
|
||||
- **disable_thinking**:网关将请求体中的 `disable_thinking:true` 透传进
|
||||
`transform_request`。DeepSeek 适配器将其映射为 `extra_body.thinking = { type = "disabled" }`
|
||||
并清理自身字段;其余适配器按各自协议处理(可自行实现或忽略)。
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
# 性能与内存
|
||||
|
||||
> 本文档由 README 拆分而来:实测内存占用、调优手段、thinking 预算。
|
||||
> 返回 [README](../README.md)。
|
||||
|
||||
## 内存占用(实测与调优)
|
||||
|
||||
内存不是一个定数:它随**配置的源数量**增长(每个源一个 `http.Transport` 与连接池、
|
||||
一组健康状态),与运行时长无关。本机实测(Linux x86_64,12 核):
|
||||
|
||||
| 部署形态 | 启动 RSS | 稳态 RSS |
|
||||
|---|---|---|
|
||||
| 1 源 / 1 适配器(最小配置) | ~4 MB | ~10 MB |
|
||||
| 1 源 + 29 MB 历史审计日志 | ~19 MB | ~20 MB |
|
||||
| **16 源 / 13 适配器 / 59 模型(本机生产)** | ~28 MB | **~32–35 MB** |
|
||||
|
||||
> 历史参考:本项优化前同一生产配置为 **~105 MB**。降幅来自三处:审计日志的**原始记录**
|
||||
> 不再常驻内存(约 25 MB;统计聚合仍扫全量,但扫完即释放)、Lua 状态池不再单调增长、
|
||||
> 以及下面两个运行时开关。
|
||||
|
||||
内存构成(生产实例分段测量,`/proc/<pid>/smaps`):
|
||||
|
||||
| 区域 | RSS | 说明 |
|
||||
|---|---|---|
|
||||
| Go 堆 | ~14 MB | provider/registry/scheduler 结构 + 连接池缓冲 |
|
||||
| 其他匹名(线程栈 / LuaJIT chunk / runtime) | ~12 MB | 与线程数、已加载适配器数相关 |
|
||||
| 二进制 text+rodata | ~8 MB | 映射的可执行文件页(只读、可被内核回收) |
|
||||
| Go runtime 预留 | ~4 MB | VSZ 上看到的 2 GB+ 是地址空间预留,不占物理内存 |
|
||||
| 共享库 | ~3 MB | libc / libluajit / libm |
|
||||
|
||||
**两个推荐的部署开关**(只适用于环境变量,无需改代码):
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/modelrouter.service
|
||||
Environment=GOGC=50
|
||||
Environment=MALLOC_ARENA_MAX=2
|
||||
```
|
||||
|
||||
- `MALLOC_ARENA_MAX=2`:LuaJIT 的分配走 cgo → glibc malloc,glibc 默认允许 `8×nproc`
|
||||
个 per-thread arena,每个碰到 malloc 的 OS 线程会占用一个(各约 1 MB,且**不归还给系统**)。
|
||||
实测从 8–15 个 arena(约 7–12 MB)降到 **0**。
|
||||
- `GOGC=50`:把 Go 堆增长目标减半。**单独使用无效**(省下的堆会立即被更多 glibc
|
||||
arena 吃掉,实测 20.3 → 21.5 MB 反而变大),必须与 `MALLOC_ARENA_MAX` 配合,
|
||||
两者同时开启才降 **~19%**。网关是 I/O 密集型(本机 9 小时仅消耗 1min10s CPU),
|
||||
多出的 GC 周期与它的空闲 CPU 相比可忽略。
|
||||
|
||||
> 桌面版(Electron)已在 `cmd/gui/main.js` 里默认为内嵌核心注入这两个开关;手动 export 同名
|
||||
> 环境变量可覆盖。如果你用自己的 systemd unit / 容器,建议照上面加上。
|
||||
|
||||
可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进,
|
||||
温和场景不必开。
|
||||
|
||||
## thinking 预算
|
||||
|
||||
请求体带 `"thinking": {"enabled": false}` 时关闭推理;`"thinking": {"enabled": true,
|
||||
"budget_tokens": 4096}` 指定预算。内核只定义这个**中立**语义字段,映射到具体协议由
|
||||
适配器负责(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig.thinkingBudget`、
|
||||
DeepSeek `thinking.type = "disabled"`)。内核不再向适配器泄漏 `disable_thinking` /
|
||||
`extra_body` 这类私有字段。
|
||||
|
||||
755
docs/plugins.md
755
docs/plugins.md
@ -1,755 +0,0 @@
|
||||
# 插件系统(Plugin System)
|
||||
|
||||
> **English**: this document is the reference for writing ModelRouter plugins.
|
||||
> The Chinese version is the primary one; section titles map 1:1.
|
||||
|
||||
ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plugin_dir` 目录里。
|
||||
插件能做两件事:
|
||||
|
||||
1. **挂钩子**:在请求流水线的若干 stage 上注册回调,看到每个请求的完整信息,
|
||||
并可以把结果累加进自己的状态。
|
||||
2. **贡献界面**:在启动时返回 HTML / CSS / JS,由内核注入 WebUI——可以是一整个
|
||||
新页面,也可以是往现有页面里追加一个组件。
|
||||
|
||||
两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。
|
||||
|
||||
---
|
||||
|
||||
## 0. 插件 SDK:唯一的公开接口
|
||||
|
||||
插件与网关之间**只有一个**公开接口表 `modelrouter`。它不是 Go 包的导出符号,
|
||||
而是内核在启动插件 VM 时注入的 Lua 表;插件通过它调用内核能力,永远不需要
|
||||
(也不应该)依赖任何 `__llmsproxy_*` 全局、Go 结构体或内部函数名。
|
||||
|
||||
```lua
|
||||
modelrouter.sdk_version -- 字符串,契约版本;SDK 形状变化才递增
|
||||
modelrouter.http(spec) -- 受出站白名单约束的 HTTP 请求
|
||||
modelrouter.secrets -- 本插件专用的凭据保险库(set/get/delete/keys)
|
||||
modelrouter.kernel -- 内核动作面(actions/call,受运维授权约束)
|
||||
modelrouter.adapters -- 安装/更新/删除本插件自己的适配器
|
||||
modelrouter.plugin_dir -- 本插件所在目录(内核给出的 ground truth)
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **只依赖上表**。内核重命名内部全局、改实现或加守卫时,插件不受影响;
|
||||
这正是把六个散落的全局收进一张表的原因。
|
||||
- **能力可能缺失**。未授予的能力(例如出站 HTTP 全禁)在表里是 `nil` 而不是
|
||||
"存在但一调用就报错",插件应先判断再使用。
|
||||
- **`sdk_version` 用来做兼容分支**。增量字段不递增版本;字段删除或调用约定
|
||||
变化才递增。
|
||||
- **历史别名**:`llmsproxy`(旧产品名)与 `__llmsproxy_{http,secrets,kernel,
|
||||
adapters,plugin_dir}` 仍然指向同一批对象,只为已安装的插件继续可用而保留,
|
||||
已废弃;新插件使用 `modelrouter.*`。
|
||||
- 环境变量:插件目录优先读 `MODELROUTER_PLUGIN_DIR`,回退旧名
|
||||
`MODELROUTER_PLUGIN_DIR`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速上手
|
||||
|
||||
一个最小的插件:
|
||||
|
||||
```lua
|
||||
-- plugins/hello.lua
|
||||
local plugin = {
|
||||
name = "hello",
|
||||
version = "1.0.0",
|
||||
description = "示例插件",
|
||||
author = "you",
|
||||
}
|
||||
|
||||
-- 声明钩子
|
||||
plugin.hooks = {
|
||||
request_end = "on_request_end",
|
||||
}
|
||||
|
||||
-- 钩子实现
|
||||
function plugin.on_request_end(payload)
|
||||
-- payload 是解码后的 table,不是 JSON 字符串
|
||||
log("info", string.format("%s via %s: %d prompt tokens",
|
||||
payload.model, payload.source, payload.prompt_tokens or 0))
|
||||
return nil -- 最后一个 stage 没有下游,return 无意义
|
||||
end
|
||||
|
||||
-- 贡献界面
|
||||
plugin.ui = {
|
||||
page = {
|
||||
page_id = "hello", -- kebab-case
|
||||
title = "Hello",
|
||||
icon = "",
|
||||
order = 90, -- 侧栏排序
|
||||
mount = [[<div id="hello">hello</div>]],
|
||||
},
|
||||
}
|
||||
|
||||
return plugin -- 必须返回一个 table
|
||||
```
|
||||
|
||||
放进 `plugin_dir` 后重启即生效。`GET /api/plugins` 确认它被加载了。
|
||||
|
||||
---
|
||||
|
||||
## 2. 加载与生命周期
|
||||
|
||||
```
|
||||
core.New
|
||||
└─ lua.NewVM(adapter_dir).Start() 适配器状态
|
||||
└─ lua.NewPlugins(vm, plugin_dir)
|
||||
`plugin_dir` 中的 Lua 文件由网关逐个加载;安装包不携带业务插件,也不会自动创建或写入插件文件。首次启用时,先把需要的 `.lua` 插件放入该目录,再启动网关。
|
||||
```
|
||||
|
||||
**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
|
||||
的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
|
||||
第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
|
||||
|
||||
插件目录由运维独立管理。网关不会 seed、覆盖或删除其中的插件;目录缺失时只会跳过插件加载,不影响网关启动。
|
||||
|
||||
### 2.1 热更新
|
||||
|
||||
| 方式 | 效果 |
|
||||
|---|---|
|
||||
| `POST /api/plugins {name, code}` | 写文件 + 立即加载新版本(旧的 Lua 状态被关闭重建,**累计量清零**) |
|
||||
| `DELETE /api/plugins/{name}` | 删文件 + 卸载 |
|
||||
| 改文件后 `POST` 同名 | 同上 |
|
||||
|
||||
改文件但**不** POST,需要重启才生效。
|
||||
|
||||
---
|
||||
|
||||
## 3. 流水线 stage
|
||||
|
||||
一个请求依次经过三个 stage。插件可以为任意 stage 注册钩子;未注册的 stage
|
||||
被忽略,所以插件不会因为网关将来新增 stage 而报错。
|
||||
|
||||
```
|
||||
客户端请求
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ request_start │ 已解析、已鉴权,尚未选源
|
||||
│ · type │ "chat" | "stream" | "image"
|
||||
│ · model │ 客户端请求的原始 model("AUTO" 也在这里)
|
||||
│ · key / role │ 掩码后的网关 key id("***a1b2c3")与角色
|
||||
│ · source │ 空(还没选源)
|
||||
│ · stream │
|
||||
│ · messages_count │
|
||||
│ · tools_count │
|
||||
│ · ts │ unix 秒
|
||||
└─────────┬──────────┘
|
||||
│ (调度:tier 遍历 → 槽位轮转 → 冷却/配额过滤)
|
||||
┌─────────▼──────────┐
|
||||
│ routed │ 已选定 (source, model),尚未发往上游
|
||||
│ · source / model │ 实际选中的
|
||||
│ · tier │ AUTO 链的档位;直连 = -1;AUTO = -2
|
||||
│ · stream / key │
|
||||
└─────────┬──────────┘
|
||||
│ (HTTP 往返 / SSE 流)
|
||||
┌─────────▼──────────┐
|
||||
│ request_end │ 每个请求恰好一次,成功失败都触发
|
||||
│ · ok / status │
|
||||
│ · latency_ms │
|
||||
│ · first_byte_ms │ 流式的首字节时间
|
||||
│ · prompt_tokens │ 上游真实 usage,缺失时为字节估算
|
||||
│ · completion_tokens
|
||||
│ · cache_hit_tokens / cache_miss_tokens
|
||||
│ · image_count │ 生图数量(图片不计 token)
|
||||
│ · error │ 失败原因,成功时为 ""
|
||||
│ · time │ unix **毫秒**
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
写审计 + 聚合统计
|
||||
```
|
||||
|
||||
### 3.1 触发点在哪
|
||||
|
||||
| stage | 代码位置 | 说明 |
|
||||
|---|---|---|
|
||||
| `request_start` | `gateway/chat.go` `handleChat` / `fireImageStart` | 每个请求一次(chat 与生图各一条),在配额闸门**之前** |
|
||||
| `chain_step` | `gateway/chat.go` `chainTraceSink` | **仅 AUTO 路径**,每步一次 |
|
||||
| `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 |
|
||||
| `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 |
|
||||
|
||||
### 3.1b 为什么单独有 `chain_step`
|
||||
|
||||
`routed` 只在**遍历结束后**触发一次,只带最终胜出的槽位。所以
|
||||
"tier 1 冷却所以降级到 tier 3"和"tier 1 正常接单"在它眼里**完全一样**——
|
||||
而这恰恰是优先级链存在的全部理由。
|
||||
|
||||
`chain_step` 补上这条信息,四种 `kind`:
|
||||
|
||||
| kind | 含义 | 何时产生 |
|
||||
|---|---|---|
|
||||
| `tier_skip` | 整档被跳过 | 该档所有槽位冷却中/配额用尽 |
|
||||
| `slot_fail` | 某个槽位硬失败 | 上游报错 / 适配器输出不可用 |
|
||||
| `tier_busy` | 整档全忙且有界等待超时 | 2s 内没等到空位 |
|
||||
| `selected` | 这个槽位接了单 | 每次成功遍历**恰好一次**,且是最后一步 |
|
||||
|
||||
顺序保证:所有 `chain_step` 都在 `routed` 之前,`selected` 是最后一步。
|
||||
所以只订阅 `request_end` 的插件也能拿到轨迹摘要(见下)。
|
||||
|
||||
### 3.1c `request_end` 里的轨迹摘要
|
||||
|
||||
除了逐个 `chain_step`,`request_end` 还带三个便于做报表的字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `chain_walk` | 整个遍历的步骤数组(上限 12 步,超出截断) |
|
||||
| `degraded` | 布尔。`true` = 有过跳过/失败,即**发生了降级** |
|
||||
| `tier_served` | 实际服务的那一档;直连或全失败时为 `-1` |
|
||||
|
||||
> **计费口径**:按**实际服务的模型**计费。降级到 tier 3 仍按 tier 3 的价算,
|
||||
> `chain_step` / `degraded` / `tier_served` 只作**观测**,不参与计价。
|
||||
> 理由见 §7.5。
|
||||
|
||||
`request_end` 放在 `writeRec` 是因为四条入口路径(直连/AUTO × 流式/非流式)都
|
||||
经过它,既不会漏(流式的 token 数只有流结束才知道),也不会重复。
|
||||
|
||||
**hot path 注意事项**:没有插件注册某 stage 时,`Fire` 立刻返回(一次 `RLock`
|
||||
加一次 map 查找)。装了插件之后,每个请求会在该 stage 上多一次 Lua 调用——
|
||||
这是同步的,在关键路径上。计费插件那种"每请求一次"是正常的;把重活放进钩子是
|
||||
反模式。
|
||||
|
||||
### 3.2 钩子的返回值
|
||||
|
||||
- 返回 `nil` 或不返回 = **没有意见**,payload 原样传给下一个插件
|
||||
- 返回 table = 其中的键会**合并进 payload**,并作为 `Fire` 的返回值
|
||||
|
||||
**同一 stage 的多个插件并行执行**,但返回值按**插件加载顺序**合并,所以结果是
|
||||
确定的(不依赖 goroutine 调度)。代价是一个插件看不到另一个插件刚加的字段:
|
||||
每个钩子拿到的是**同一份 payload 快照**。
|
||||
|
||||
这与早期版本不同 —— 早期是顺序执行,后一个插件能看到前一个的返回值。它从未被
|
||||
实际依赖(随核心发布的 billing 在每个 stage 都 `return nil`,注释里写着
|
||||
"nobody downstream would read a return value"),但这是一处**契约变化**:如果你的
|
||||
插件依赖「读到前一个插件写的字段」,并行的两个插件之间必须改用外部通信
|
||||
(例如各自写 `plugin.state`,由 `/api/plugins/<name>/state` 读取)。
|
||||
|
||||
单个插件时不启 goroutine,直接调用。
|
||||
|
||||
前三个 stage 的返回值目前没有内部消费者(最后一个 stage 之后就是写审计),
|
||||
所以计费插件改用 `plugin.state` + `/state` 端点来暴露数据。
|
||||
|
||||
---
|
||||
|
||||
## 4. 界面扩展
|
||||
|
||||
### 4.1 整页
|
||||
|
||||
```lua
|
||||
plugin.ui = {
|
||||
page = {
|
||||
page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
|
||||
title = "Billing", -- 必填,侧栏文字
|
||||
icon = "", -- 可选
|
||||
order = 40, -- 侧栏排序,默认 100
|
||||
mount = [[...HTML...]],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 往现有页面追加元素
|
||||
|
||||
```lua
|
||||
plugin.ui = {
|
||||
elements = {
|
||||
{
|
||||
target = "status", -- status | chat | keys | sort | sources | adapters
|
||||
anchor = "top", -- "top" | "bottom" | "before:<sel>" | "after:<sel>"
|
||||
order = 5,
|
||||
mount = [[...HTML...]],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `mount` 里可以带 `<script>` 和 `<style>`
|
||||
|
||||
内核的注入顺序是:**先插 HTML,再执行 `<script>`**,所以脚本里访问
|
||||
`document.getElementById` 一定能拿到已渲染的节点。
|
||||
|
||||
### 4.4 插件可用的浏览器端 API
|
||||
|
||||
| API | 作用 |
|
||||
|---|---|
|
||||
| `window.pluginAPI.fetchState(name)` | 等价于 `GET /api/plugins/<name>/state` |
|
||||
| `window.pluginAPI.onTabShown(fn)` | 注册"页面切到可见时"的回调(轮询类组件用) |
|
||||
| `window.pluginAPI.postState(name, obj)` | `PUT` 自己的 state(写操作,需 admin) |
|
||||
|
||||
### 4.5 一个完整例子
|
||||
|
||||
见 `internal/lua/plugins/billing.lua`——它同时用了两种 UI 形式、完整的价格配置、
|
||||
以及全部三个 stage。
|
||||
|
||||
---
|
||||
|
||||
## 5. 插件状态与 HTTP API
|
||||
|
||||
### 5.1 端点
|
||||
|
||||
```
|
||||
GET /api/plugins 已加载插件清单 + 钩子 + UI + 错误
|
||||
POST /api/plugins 上传/替换(admin){name, code}
|
||||
DELETE /api/plugins/{name} 删除(admin)
|
||||
GET /api/plugins/{name}/state 读取插件自己发布的状态
|
||||
PUT /api/plugins/{name}/state 替换状态(admin)
|
||||
```
|
||||
|
||||
`GET .../state` 对**任意角色开放**:它是报表数据(开销、计数),用户自己的
|
||||
计费组件要能渲染。而 `PUT` 需要 admin。
|
||||
|
||||
### 5.2 约定:`prices` 与 `state` 分离
|
||||
|
||||
`PUT .../state` 的载荷里如果有 `prices` 键,它会被写进插件的 **`plugin.prices`**
|
||||
字段并从 `state` 里剔除。
|
||||
|
||||
**为什么**:`state` 是累计量(历史),`prices` 是配置。一次改价如果整体替换
|
||||
`state`,累计量就没了——账目会在改价那一刻清零。分离之后:
|
||||
|
||||
- 只带 `prices` → 改配置,**不动** `state`(累计量保留)
|
||||
- 带其它键 → 替换 `state`(这是显式的重置)
|
||||
|
||||
> 插件可以不遵守这个约定,把整个载荷放进 `state` 自行处理。约定只为内置的
|
||||
> 计费插件存在。
|
||||
|
||||
### 5.3 `GET /api/plugins` 的响应
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [{
|
||||
"name": "billing", "version": "1.0.0",
|
||||
"description": "...", "author": "...",
|
||||
"hooks": ["request_end"],
|
||||
"ui": { "page": "billing", "elements": 1 },
|
||||
"loaded": true
|
||||
}],
|
||||
"hook_errors": { "request_end": { "count": 3, "last_error": "billing: ..." } },
|
||||
"plugin_dir": "/etc/modelrouter/plugins"
|
||||
}
|
||||
```
|
||||
|
||||
`hook_errors` 是**排错入口**:一个坏掉的插件表现为"功能缺失"而不是报错,
|
||||
这个计数让它可见。
|
||||
|
||||
### 5.4 定时任务(`PLUGIN.schedule`)—— 浏览器关着也会跑
|
||||
|
||||
要求插件做“随时间发生”的事(定时刷新、token 轮换、指标采集)时,声明一个
|
||||
调度表:
|
||||
|
||||
```lua
|
||||
plugin.schedule = {
|
||||
{ fn = "refresh_token", every = 3600 }, -- 每小时(秒)
|
||||
{ fn = "daily_refresh", every = 86400, at = "03:30" }, -- 每天固定时刻(本地时间)
|
||||
{ fn = "poll_quota", every = 300, jitter = 30 }, -- 首次触发加 0-30s 抖动
|
||||
}
|
||||
function plugin.refresh_token()
|
||||
-- 注意:直接写 plugin.state 就是对的 ——
|
||||
-- 任务和钩子跑在同一个 VM 上
|
||||
plugin.state.token = fetch_new_token()
|
||||
end
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `fn` | 要调用的插件函数名(必须存在) |
|
||||
| `every` | 间隔秒数。与 `at` 二选一 |
|
||||
| `at` | 每天本地时间 `"HH:MM"`;设了它就忽略 `every` |
|
||||
| `jitter` | 首次触发额外随机延迟秒数(避免多实例整点齐射) |
|
||||
| `timeout` | 解析保留但目前**不生效**(见下) |
|
||||
|
||||
要点:
|
||||
|
||||
- **内核 ticker 驱动**,不是前端轮询:没有浏览器、没人登录也会执行。
|
||||
- **任务跑在插件自己的 VM 上**,与请求钩子共用 `plugin.state` —— 任务写的
|
||||
token 对下一个请求立即可见,也会经同一套机制持久化到 `.<name>.state.json`。
|
||||
- 任务函数出错只记 `hook_errors`,不影响钩子与转发。
|
||||
- 没有 `schedule` 的插件不获得任何额外 VM/goroutine(零开销)。
|
||||
- **超时不能中断一个运行中的 Lua 调用**(本 LuaJIT 构建的硬限制)。任务超过
|
||||
10 分钟不返回,该插件被**停用**(`disarmed`):不再调度,钩子快速失败并
|
||||
记 `hook_errors`。这是为了不让一个卡死的任务把请求永久堵在它那把锁后面。
|
||||
修好插件后重新加载(重传/重启)即可恢复。
|
||||
|
||||
> 同一条 6.1 的约束:任务占着插件锁,所以**任务里不要写死循环**。
|
||||
|
||||
### 5.5 自定义对外挂载点
|
||||
|
||||
插件的 HTTP 端点默认挂在 `/api/plugins/http/...` 下。同一个声明**同时**可在两个
|
||||
公开命名空间下访问,只要把 `path` 的第一段起成插件名:
|
||||
|
||||
```lua
|
||||
plugin.http = {
|
||||
{ path = "myplugin/callback", method = "POST", handler = "on_callback" },
|
||||
}
|
||||
```
|
||||
|
||||
该端点因此可在三个地方访问:
|
||||
|
||||
| 路径 | 用途 |
|
||||
|---|---|
|
||||
| `/api/plugins/http/myplugin/callback` | 默认前缀(带登录态的管理 UI) |
|
||||
| `/plugins/myplugin/callback` | 公开(浏览器路径) |
|
||||
| `/v1/plugins/myplugin/callback` | 公开(API 路径) |
|
||||
|
||||
可占用的命名空间只有后两个:
|
||||
|
||||
| 前缀 | 例子 |
|
||||
|---|---|
|
||||
| `/plugins/` | `/plugins/myplugin/callback` |
|
||||
| `/v1/plugins/` | `/v1/plugins/myplugin/callback` |
|
||||
|
||||
限制(都是为了不把内核路由变成可被插件改写的):
|
||||
|
||||
- **不能影子内核路由**:与 `/v1/chat/completions`、`/v1/messages`、`/api/...`
|
||||
等重叠的挂载在加载时就被拒绝,错误信息点名冲突对象。
|
||||
- **只能是上面两个命名空间**,不接受任意前缀(否则可以拦截推理请求)。
|
||||
- 鉴权沿用端点的 `roles` / `keys` / `public` 语义(见 §5.1);
|
||||
`/v1/plugins/...` 属于 API 路径,未认证返回 401;`/plugins/...` 为浏览器
|
||||
路径,未认证会跳登录页。
|
||||
- 未声明的路径不会因为落在允许命名空间而被放行——不在 handler 表里就是 404。
|
||||
|
||||
### 5.6 前缀代理:把插件自起的服务挂到网关同端口
|
||||
|
||||
前节的自定义挂载点只能回答**由插件计算出来的内容**(`{status, headers, body/json}`)。
|
||||
若插件自己起了一个 TCP/HTTP 服务(典型:服务端浏览器栈 + noVNC),客户端就需要
|
||||
**字节级转发**,包括 **WebSocket 升级** —— 前者做不到。
|
||||
|
||||
为此声明 `prefix = true`,handler 每次请求返回一个**回环目标**:
|
||||
|
||||
```lua
|
||||
plugin.http = {
|
||||
{ path = "myplugin/proxy/:session", method = "GET", prefix = true,
|
||||
roles = { "admin" }, handler = "proxy" },
|
||||
}
|
||||
|
||||
function plugin.proxy(req)
|
||||
-- 每次请求都问一遍:会话没了/过期了就返回 404,地址自然失效
|
||||
if not sessionAlive(req.params.session) then
|
||||
return { status = 404, json = { error = "no such session" } }
|
||||
end
|
||||
return { proxy = "http://127.0.0.1:60100" }
|
||||
end
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **只能返回 `{ proxy = <目标> }`**,没有 `status` / `body`。写了 `status` 会被忽略。
|
||||
- 目标必须是 **带显式端口的字面回环 IP**(`http://127.0.0.1:60100`)。
|
||||
拒绝 `https://`、`localhost` 这类主机名、无端口、带 userinfo/path/query 的地址。
|
||||
理由:主机名可能被 hosts / DNS / 代理环境改写;通用能力只接受确定性地址。
|
||||
- 路由匹配同前缀下的**所有子路径**,`:param` 仍照常捕获,`req.path` 是完整
|
||||
请求路径,子路径由插件自己从 `req.path` 里切。
|
||||
- **鉴权先于代理**:`roles` / `keys` / `public` 语义与普通端点一致。
|
||||
- **不转发网关凭据**:`Authorization`、`Cookie`、`api_key` 都不会传给后端服务。
|
||||
- **拒绝跨站**:`Origin` 与网关自身 host 不一致返回 403。WebSocket 由浏览器
|
||||
发起且自动带 Cookie,否则第三方网页可借管理员会话开隧道。
|
||||
- **撤销点唯一**:handler 不再返回目标(或返回 404)即失效,内核不缓存目标。
|
||||
|
||||
客户端拿到的 URL 是**相对网关口**的绝对路径(如
|
||||
`/api/plugins/http/myplugin/proxy/<id>/vnc.html`),因此远程访问、HTTPS 入口、
|
||||
Cookie 鉴权都自然生效,无需暴露额外端口。
|
||||
|
||||
### 5.7 在运行时安装/更新适配器(token 轮换)
|
||||
|
||||
插件可以在运行时安装、改写**自己的**适配器 —— 这是"带自动轮换的认证"的实现
|
||||
方式:拿着新 token 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器
|
||||
的池)。**可直接在插件 `.lua` 里调用**:
|
||||
|
||||
```lua
|
||||
-- 每小时的定时任务:轮换 token 并重写适配器
|
||||
function plugin.refresh_token()
|
||||
local tok = fetch_new_token() -- 插件自己的取 token 逻辑
|
||||
local name, err = modelrouter.adapters.install(
|
||||
"myauth",
|
||||
'return { name = "myauth", endpoint = "/chat/completions",' ..
|
||||
' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }'
|
||||
)
|
||||
if err then log("error", err) end
|
||||
end
|
||||
```
|
||||
|
||||
| 调用 | 参数 | 返回 |
|
||||
|---|---|---|
|
||||
| `modelrouter.adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` |
|
||||
| `modelrouter.adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
|
||||
| `modelrouter.adapters.remove(name)` | 适配器名(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
|
||||
|
||||
出错时不抛异常,而是返回 `(nil, err)`——与钩子/HTTP 处理器同一套约定,
|
||||
一个手滑的插件错误不会带走网关。
|
||||
|
||||
安全规则(由 Go 侧强制,不是约定):
|
||||
|
||||
- 插件**只能改自己装的**适配器(归属表在 Go 侧记录,调用身份是插件启动时的
|
||||
固定名字,无法冒充)。
|
||||
- 插件**不能影子内置适配器**(否则等于篡改发行物);运维仍可自行覆盖。
|
||||
- 名字经过路径安全校验(无穿越/绝对路径/空白)。
|
||||
|
||||
> 这条能力配合 §5.4 的定时任务,就是完整的自动轮换:**没有浏览器、没有人
|
||||
> 操作时,插件按时间自己把 token 换掉**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 运行时约束(重要)
|
||||
|
||||
### 6.1 一个插件 = 一个 Lua 状态(也就是一个 VM)
|
||||
|
||||
适配器可以有多个独立的 worker 状态(它们无状态,这是对的)。**插件不行**:
|
||||
钩子通常往 `plugin.state` 里累加,多个状态就意味着总额被劈成几份,而写价格只
|
||||
写进其中一个状态,钩子恰好跑到另一个时**所有请求按 0 计费**。
|
||||
|
||||
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**)
|
||||
都用这同一把互斥锁串行化。
|
||||
|
||||
注意: 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
|
||||
`plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了,
|
||||
对实际流量完全无效。
|
||||
|
||||
代价:**一个卡住的钩子会卡住该插件自己的钩子**(跨插件仍然并行,每插件各自
|
||||
一把锁、各自一个 VM)。所以——
|
||||
|
||||
> **钩子与定时任务都必须短、同步、不阻塞。** 不要在钩子里做 HTTP 请求、睡眠
|
||||
> 或重计算;长耗时的周期工作应放进 `PLUGIN.schedule`(见 §5.4),它在自己的
|
||||
> 调度 goroutine 上跑,但仍然持有这把锁,因此不能是死循环。
|
||||
|
||||
### 6.2 异常被隔离
|
||||
|
||||
钩子里 `error()` 不会影响转发:异常被捕获、记进 `hook_errors`、跳到下一个插件。
|
||||
适配器变换出错会让源进入冷却,但插件出错**不产生任何惩罚**——插件是可选功能。
|
||||
|
||||
### 6.3 内置可用函数
|
||||
|
||||
与适配器相同:`json.encode` / `json.decode`、`log(level, msg)`、
|
||||
`hmac_sha256_hex` / `sha256_hex` / `base64_encode` / `tohex`。
|
||||
|
||||
注意: `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
|
||||
|
||||
### 6.4 命名
|
||||
|
||||
- 全局 `__llmsproxy_plugin` 是内核存放插件返回 table 的**内部位置**,不属于 SDK,**不要占用**
|
||||
- 插件间状态完全隔离,一个插件改不了另一个的全局
|
||||
|
||||
---
|
||||
|
||||
## 7. Billing 插件示例
|
||||
|
||||
`internal/lua/plugins/billing.lua` 是仓库中的可选插件示例,不随核心安装包分发,也不会在启动时自动安装。需要使用计费功能时,应将该脚本单独复制到配置的 `plugin_dir` 后再重启网关。
|
||||
|
||||
### 7.0 分层:billing 完全在插件里,内核没有任何 billing 代码
|
||||
|
||||
`billing` 是**插件自己的事**:规则文件、编译、计价、账目、规则编辑页都由
|
||||
`plugins/billing.lua` 负责。内核里没有 billing 的配置结构、端点、编译或迁移逻辑
|
||||
—— 它只提供所有插件通用的能力(加载 `.lua`、插件状态持久化、插件目录、
|
||||
`plugin.http` 路由、插件可用的 `io`/`os`)。
|
||||
|
||||
| 项 | 位置 |
|
||||
|---|---|
|
||||
| 规则文件 | `<plugin_dir>/billing.json`(插件自己读写;内核不知道这个文件) |
|
||||
| 计费账目 | 插件状态文件 `<plugin_dir>/.billing.state.json`(内核的通用状态持久化) |
|
||||
| 规则编辑 API | `/api/plugins/http/billing/rules`(GET/PUT/POST/DELETE,插件路由) |
|
||||
| 编译/计价 | 插件内 `compileRules()` / `priceFor()` / `costFor()` |
|
||||
|
||||
规则以**源名**(`source`)为键,而不是 URL:URL→源名的映射属于内核配置,插件
|
||||
不需要为了它和内核对接;编辑页在浏览器里,直接取 `/api/sources` 给运营选源名。
|
||||
`url: "*"` 只表示"全局模型价目表"(模型维度本来就不分源)。
|
||||
|
||||
> 历史:早期 `billing:` 写在 `config.yaml`、编译在 Go 的 `internal/billing` 里。
|
||||
> GitCode issue #1 的根因正是"Go 与 Lua 各写一半覆盖语义",组合起来把模型价
|
||||
> 静默清零。现在内核侧 billing 代码已全部删除,规则文件也由插件自持。
|
||||
|
||||
### 7.1 计费维度### 7.1 计费维度
|
||||
|
||||
| 维度 | 用途 | token 价覆盖顺序 |
|
||||
|---|---|---|
|
||||
| `default` | 兜底价(通常全 0) | 最低 |
|
||||
| `models` | 模型的 per-token 价 | 中 |
|
||||
| `sources` | 源级价:固定价,或覆盖该源全部模型的 token 价 | 高 |
|
||||
| `keys` | 单个网关 key 的覆盖价 | **最高** |
|
||||
|
||||
**token 价**优先级:`keys` > `sources` > `models` > `default`。
|
||||
(实现顺序就是"后写覆盖前写":`default` → `models` → `sources` → `keys`;
|
||||
所以**源级 token 价会覆盖模型价**。这是有意的:运营要能给某个源整体改价。)
|
||||
|
||||
⚠️ 正因为 `sources` 会覆盖 `models`:**`mode: token` 的规则不产生任何源条目**。
|
||||
规则未列出的模型会进入未定价统计(可见),而不是静默计 0 —— 这是 issue #1 / PR #2
|
||||
的收口语义。只有 `free` / `subscription`("边际成本为零")会写有意为之的
|
||||
`{prompt:0, completion:0}` 零价条目。
|
||||
|
||||
规则写法:
|
||||
|
||||
```json
|
||||
{"active":"current","profiles":[{"id":"current","default":"unpriced","rules":[
|
||||
{"source":"localzen","mode":"free"},
|
||||
{"source":"codearts","mode":"token",
|
||||
"models":{"glm-5.3":{"prompt":"0.15","completion":"0.60"}}},
|
||||
{"url":"*","mode":"token","models":{"some-model":{"prompt":"1.0","completion":"2.0"}}}
|
||||
]}]}
|
||||
```
|
||||
|
||||
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
||||
**`cache_discount`** 见 §7.7。
|
||||
|
||||
### 7.2 价格单位
|
||||
|
||||
**USD / 单个 token**。这是各家 provider 的公布口径,所以典型数值长这样:
|
||||
`1.25e-6`。插件内部乘以 token 数,全程不做单位换算。
|
||||
|
||||
```json
|
||||
{
|
||||
"prices": {
|
||||
"currency": "USD",
|
||||
"default": { "prompt": 0, "completion": 0, "per_request": 0 },
|
||||
"sources": { "trae": { "per_request": 0.01 } },
|
||||
"models": {
|
||||
"gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5 },
|
||||
"kolors": { "per_request": 0.04 }
|
||||
},
|
||||
"keys": { "***a1b2c3": { "prompt": 1.1e-6, "completion": 9e-6 } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置价目:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8080/api/plugins/billing/state \
|
||||
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"prices":{"models":{"gpt-5.4":{"prompt":1.25e-6,"completion":1e-5}}}}'
|
||||
```
|
||||
|
||||
读回账目:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $ADMIN_KEY" \
|
||||
http://127.0.0.1:8080/api/plugins/billing/state
|
||||
```
|
||||
|
||||
### 7.3 累计维度
|
||||
|
||||
`total` / `by_source` / `by_model` / `by_key` / `by_day`(`YYYY-MM-DD` UTC),
|
||||
每项含 `cost`、`requests`、`prompt_tokens`、`completion_tokens`、`failures`。
|
||||
|
||||
另有两个**降级观测**维度(来自 `chain_step`):
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `degraded_reqs` | 发生过降级的请求数 |
|
||||
| `by_tier_served` | 各档实际接单数(`{"1": 812, "2": 37}`) |
|
||||
| `skip_reasons` | 跳过原因计数,等待时长已归一(`no free slot within <wait>`) |
|
||||
|
||||
这三项是"网关是不是在悄悄降级"的核心指标:一个持续降级的网关,账单结构和健康
|
||||
网关看起来一模一样——**除非**单独统计降级次数。
|
||||
|
||||
### 7.4 计费策略:失败的请求怎么算
|
||||
|
||||
**保留 token 费用,丢弃固定费用。** 理由:上游在生成后才 500,token 确实被消耗
|
||||
了;但那个从未真正发生的固定费不该收。
|
||||
|
||||
这是本插件里**最可争议的一条**。想改成"失败也收固定费":
|
||||
|
||||
```json
|
||||
{ "prices": { ... }, "count_failures": true }
|
||||
```
|
||||
|
||||
### 7.5 注意: 计费插件只报表,不执法
|
||||
|
||||
**网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。**
|
||||
本插件不参与任何路由或配额决策。
|
||||
|
||||
理由:两套独立的会计路径如果对不上,比一套功能略少的更糟。计费是**观察**,
|
||||
配额是**控制**,二者分开。
|
||||
|
||||
### 7.6 未定价流量(重要)
|
||||
|
||||
**任何维度都没配价的请求,成本记 0。** 这是最危险的失败模式:账单照样能加总,
|
||||
只是**悄悄少报**,而且没有任何报错。
|
||||
|
||||
所以插件单独统计它们:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `unpriced_reqs` | 没有任何价目覆盖的请求数 |
|
||||
| `unpriced_models` | 按模型点名(`{"MYSTERY-MODEL": 12}`)——直接告诉你价目表缺了哪一行 |
|
||||
|
||||
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
||||
|
||||
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认)。
|
||||
它是插件运行时的策略(编译期写入 `prices.default_mode`):
|
||||
|
||||
| `default` | 没有单独规则的源 | `unpriced_reqs` |
|
||||
|---|---|---|
|
||||
| `unpriced`(省略时) | 零价,计入未定价债务 | +1 |
|
||||
| `free` | 零价,视为**刻意免费** | 不增加 |
|
||||
|
||||
显式 `mode: unpriced` 的源即使在 `default: free` 下也仍然计入("只统计不收费"
|
||||
的声明不能被兜底策略吞掉)。
|
||||
|
||||
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
||||
`total` 与各维度,只有金额是 0。
|
||||
|
||||
### 7.7 提示缓存计价
|
||||
|
||||
**缓存命中的 prompt token 不按全价算。** 绝大多数 provider 对缓存读给很深的折扣
|
||||
(常见是 1/10),而 agent 流量会反复重放长前缀——正是缓存要让它便宜的那类流量。
|
||||
|
||||
```
|
||||
fresh = prompt_tokens - cache_hit_tokens → 全价
|
||||
cached = cache_hit_tokens → 全价 × cache_discount
|
||||
```
|
||||
|
||||
`cache_discount` 默认 **0.1**(10 倍折扣),因为 DeepSeek / Qwen / Kimi 等都是这个
|
||||
量级。它是**每个 provider 的事实、不是自然常数**,所以可以按条目覆盖:
|
||||
|
||||
```json
|
||||
"models": { "gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5, "cache_discount": 0.25 } }
|
||||
```
|
||||
|
||||
设成 `1` 恢复成旧的"prompt 一律全价"行为,设成 `0` 表示该 provider 不打折。
|
||||
|
||||
优先级与 token 价一致(`keys` > `models` > `default`)。
|
||||
|
||||
> **这一条改过行为。** 修复前缓存命中按全价算:100 万 prompt token 里 90 万是
|
||||
> 缓存命中,会算出 10 USD 而不是 ~1.9——**高估约 10 倍**,而且恰好发生在缓存
|
||||
> 最有价值的高频流量上。
|
||||
|
||||
### 7.8 数据从哪来
|
||||
|
||||
`request_end` 的 `prompt_tokens` / `completion_tokens` 优先取上游真实的
|
||||
`usage`;上游没报时网关用字节估算(`len/3+1`)。流式请求在流结束后用上游真实
|
||||
数字**覆盖**估算值。所以计费数字的精度取决于上游是否报 usage。
|
||||
|
||||
---
|
||||
|
||||
## 8. 排错
|
||||
|
||||
| 现象 | 查什么 |
|
||||
|---|---|
|
||||
| 插件没出现在 `/api/plugins` | 目录对不对(响应里的 `plugin_dir`);文件是不是 `.lua` |
|
||||
| `loaded: false` 且有 `error` | 语法错误或没 `return table`,错误信息在 `error` 字段 |
|
||||
| 功能"没反应"但无报错 | 查 `hook_errors`——坏钩子只记录不抛出 |
|
||||
| 钩子没被调用 | 该 stage 确实触发了吗(`routed` 只在**成功**选源后触发,调度全失败时不触发) |
|
||||
| 界面空白 | 内核的 `GET /api/ui-inject` 里有没有你的 `page_id`;脚本有没有报错(浏览器 console) |
|
||||
| `pluginAPI` 未定义 | 脚本在注入前执行了;确认用的是 `mount` 而不是别的方式插入 |
|
||||
|
||||
### 调试钩子
|
||||
|
||||
插件的 `log(level, msg)` 输出到网关的日志:
|
||||
|
||||
```
|
||||
journalctl -u modelrouter -f | grep -i plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 与适配器的区别
|
||||
|
||||
| | 适配器 | 插件 |
|
||||
|---|---|---|
|
||||
| 位置 | `adapter_dir` | `plugin_dir` |
|
||||
| 作用 | 上游协议转换 | 请求流水线 + 界面 + 周期任务 |
|
||||
| 钩子 | `transform_request` / `transform_response` / `transform_stream_chunk` / `build_headers` / `transform_error` / `serialize_*` | `request_start` / `routed` / `request_end` |
|
||||
| 周期任务 | **无**(无状态请求级) | `PLUGIN.schedule`(内核 ticker 驱动) |
|
||||
| 对外端点 | 不开放(见 §5.5) | `/api/plugins/http/...` + 自定义挂载 |
|
||||
| 状态 | 每个源独立,多 worker | **单 VM、单状态**,见 §6.1 |
|
||||
| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失;卡死则停用该插件 |
|
||||
| 鉴权 | 需要 `gateway_keys` 之外的独立凭据 | 网关 key |
|
||||
| 必需性 | 核心 | 可选,缺了网关照常跑 |
|
||||
|
||||
完整适配器协议见 [lua-adapters.md](lua-adapters.md)。
|
||||
111
docs/routing.md
111
docs/routing.md
@ -1,111 +0,0 @@
|
||||
# 路由与调度
|
||||
|
||||
> 本文档由 README 拆分而来:模型路由、AUTO 链、冷却与半冷却、源与适配器、源模板。
|
||||
> 返回 [README](../README.md)。
|
||||
|
||||
## 模型路由
|
||||
|
||||
`/v1/chat/completions` 的 `model` 解析顺序:
|
||||
|
||||
1. `source/model` 或 `source:model` 前缀 → 指定源;
|
||||
2. 精确匹配某个源的 `model`;
|
||||
3. 配置 `default_model: AUTO` 时 → 走优先级页保存的 AUTO 链(见下);
|
||||
4. 否则回落(返回错误)。
|
||||
|
||||
任何 OpenAI 客户端,只要 `model` 设为某个源的 `name/任意名`,即可锁定走该源;
|
||||
设为 `AUTO`(或网关配了 `default_model: AUTO`)即自动按优先级链选源。
|
||||
|
||||
## AUTO 链(优先级页)
|
||||
|
||||
AUTO 调度**只**由优先级页保存的规则决定,源配置里的 `models[].priority` 数字不再
|
||||
参与调度、也不再显示。**聊天与生图是两套独立的链**,在优先级页面通过「聊天 / 生图」
|
||||
按钮切换编辑:
|
||||
|
||||
- **聊天链**(`runtime_file` 的 `auto` 字段):服务 `POST /v1/chat/completions`
|
||||
的 `model: AUTO` 请求;只接受 `kind: chat` 模型,配置在链里的生图槽位会被忽略。
|
||||
- **生图链**(`runtime_file` 的 `auto_image` 字段):服务 `POST /v1/images/generations`
|
||||
的 `model: AUTO` 请求;只接受 `kind: image` 模型。未配置生图链时,AUTO 生图回落
|
||||
到「所有含生图模型的源、按注册顺序」的旧行为,兼容旧配置。
|
||||
|
||||
每条规则 = 一个「槽位」:`{ model, source, tier, token_quota, period, hours }`。
|
||||
|
||||
- `tier` 表示优先级档位:同一档的模型并排、共享该优先级;档位从上到下递减。
|
||||
- 同一模型可配置多个槽位(如 A 源低配 → B 源低配 → A 源高配 → B 源高配),按档位顺延。
|
||||
- `token_quota` > 0 时该槽位在重置周期内用满即顺延到下一槽位;`period` 支持
|
||||
`hour` / `week` / `month` / `nhour`(配合 `hours`),空 = 不限。
|
||||
- 同 tier 内按「偏好分」排序,失败槽位冷却后自动跳过;冷却 / 配额耗尽 / hard
|
||||
错误会顺延到下一 tier,全部失败时返回 503 并附上每档失败摘要。
|
||||
|
||||
## 冷却与半冷却探测(自愈调度)
|
||||
|
||||
槽位失败后进入指数退避冷却(5s → 10s → 20s …,上限 **5 分钟**)。冷却不是全时段
|
||||
硬闸:
|
||||
|
||||
- **前半段完全静默**:刚失败的槽位不接任何流量,避免对故障上游持续施压。
|
||||
- **后半段放行 1 个探测**:窗口过半后,同一 `(源, 模型)` 最多允许 **一个** 请求作为
|
||||
探测通过。探测槽数 = `clamp(max_concurrent / 10, 1, 2)`——`max_concurrent: 10`
|
||||
的源恰好放行 1 个探测请求。
|
||||
- **探测排在最后**:同 tier 内探测候选永远排在健康候选之后,只有没有正常槽可用时
|
||||
才承接真实流量,因此探测不会抢走可用容量。
|
||||
- **探测即真实请求**:成功则立即清零失败计数与冷却,槽位当场回到正常轮询——
|
||||
上游恢复/额度恢复后不必等满整个冷却窗口。失败则重开一个新窗口,下次探测顺延到
|
||||
新窗口的中点,不会连续重试。
|
||||
|
||||
分类冷却策略:
|
||||
|
||||
| 情况 | 冷却 | 说明 |
|
||||
|---|---|---|
|
||||
| 传输错误 / 5xx | 5s→10s→20s…,上限 5min | 指数退避 |
|
||||
| 401 / 403 凭据错误 | 10min | 换 key 后由下一次探测自动接回 |
|
||||
| 429 限流 | 30s 固定 | 「太快」不等于「坏了」,不进指数阶梯 |
|
||||
| 配额耗尽(`insufficient_quota` / `余额不足` 等) | 对齐配额窗口,上限 30min | **不进指数阶梯、不涨失败计数**,额度恢复即可用 |
|
||||
|
||||
WebUI「优先级」页的槽位健康标签会区分 `冷却` / `待探测` / `探测中`,悬停可看窗口
|
||||
起点、探测放行时刻与完全恢复时刻。
|
||||
|
||||
## 源(Source)与适配器(Adapter)的关系
|
||||
|
||||
- **源** 是到某个上游的连接描述:`name`、`base_url`、`api_key`、模型列表与优先级。
|
||||
- **适配器** 是协议转换逻辑(Lua 脚本):把统一 OpenAI 格式请求转成上游原生格式,
|
||||
再把上游响应/流式分块转回统一格式。
|
||||
- 一个适配器可被多个源复用(如 `openai.lua` 同时服务多个 OpenAI 兼容站点);
|
||||
同一个源也可以切换不同适配器(改 `adapter` 字段即可)。
|
||||
- 源决定“连谁、暴露哪些模型”,适配器决定“怎么对话”——二者在 `sources[]` 条目中
|
||||
通过 `adapter` 字段关联。
|
||||
|
||||
加载流程(`internal/core` 的 `Core` 负责装配):
|
||||
|
||||
1. 启动时 `lua.NewVM(adapter_dir)` 加载全部适配器:内置适配器(编译期 embed)+
|
||||
`adapter_dir` 下的同名覆盖文件;内置文件写在代码内,覆盖文件要求更高优先级。
|
||||
2. `config.Load` 读取 `config.yaml`,`config.NewStore(runtime_file)` 读 WebUI 改动的
|
||||
运行时源,二者按名称合并成完整源列表。
|
||||
3. `rebuildRegistry` 为每个源创建 `provider.Provider`(持有目标适配器),并按源的
|
||||
并发上限配置适配器 worker 池**上限**(不预分配,见下)。
|
||||
4. 请求进来时 `Registry` 按 `model` 路由到 Provider,Provider 调适配器
|
||||
`transform_request` → HTTP 发送 → `transform_response` / `transform_stream_chunk`。
|
||||
|
||||
WebUI 上的"新增/编辑源"、"上传 Lua 适配器"、"改 AUTO 优先级链"与"密钥模型范围"
|
||||
都即时生效(写入运行时文件或 `adapter_dir` 后重新装配,无需重启);直接编辑
|
||||
`config.yaml` / `adapter_dir` 下的文件则需要重启进程才会重新加载。
|
||||
|
||||
在 WebUI 删除源/适配器时:运行时(WebUI 创建)的源与 `.lua` 文件会真正移除;
|
||||
`config.yaml` 中定义的基源无法改写配置文件,采用删除标记隐藏(重启后仍隐藏),
|
||||
在 UI 里重新添加同名源即可恢复。
|
||||
|
||||
## 源模板(多 key 负载均衡)
|
||||
|
||||
多个上游 key 共享同一套配置(URL / 适配器 / 模型列表 / 并发 / rpm)时,逐一复制
|
||||
整段 source 太冗余。**源模板**保存除 `name` 和 `api_key` 以外的全部源字段,
|
||||
从模板一键创建多个仅 key 不同的源,天然支持多 key 负载均衡(每个展开源独立调度、
|
||||
独立冷却、独立健康状态)。
|
||||
|
||||
- WebUI「源」页右上角「**模板管理**」按钮:列出全部模板,可编辑 / 删除 / 新建。
|
||||
- 添加源弹窗右上角两个按钮:
|
||||
- **从模板创建** → 选模板 → 表单自动填充(name + key 仍需你填);
|
||||
- **存为模板** → 把当前表单非 key/name 字段存为新模板。
|
||||
- 模板持久化在 `runtime_file` 的 `source_templates` 字段,与运行时源一样热管理,
|
||||
改完即时生效。模板本身不含密钥,可以安全共享 / 纳管。
|
||||
|
||||
模板不会自己变成源——它只是「配方」。真正承担流量的是从模板创建出来的、带 key
|
||||
的具体源。
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
# WebUI
|
||||
|
||||
> 本文档由 README 拆分而来。
|
||||
> 返回 [README](../README.md)。
|
||||
|
||||
## WebUI
|
||||
|
||||
内置管理界面(`GET /`),登录后可在浏览器完成:
|
||||
|
||||
- **状态页**:源在线状态(实时探测 + 悬停看错误)、模型/源/key 用量统计、请求记录,
|
||||
支持按时间范围导出 CSV;点击模型可生成 pin 到该模型的连接配置。
|
||||
- **对话页**:流式/非流式调试。
|
||||
- **密钥页**:创建/编辑网关 key,为每个 key 配模型范围(模型 + 源 + token 配额 + 周期),
|
||||
管理员管理全部 key,用户只看到自己的 key。每个模型砖块显示自己的配额徽标
|
||||
(如 `1.0K 77×·1h`,未设配额显示 `∞`),点开可编辑该模型的 token 配额、
|
||||
请求数配额与重置周期 —— 配额按模型独立生效,一个用满不影响同一 key 的其它模型。
|
||||
「我的密钥」页对用户展示本 key 的模型范围与各自配额。
|
||||
- **优先级页**:拖拽积木配置 AUTO 链档位。
|
||||
- **源页**:在线增删改上游源(API key 等敏感字段加密落盘)。
|
||||
- **适配器页**:上传 / 删除 Lua 适配器脚本。
|
||||
|
||||
改动写入 `runtime_file`(重启仍生效)。
|
||||
|
||||
@ -1,286 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件端到端验证 R2/R5 的核心主张:**对外服务的 wire 格式由适配器决定,
|
||||
// 内核只调度**;适配器不声明时内核的 OpenAI 兜底仍然可用。
|
||||
//
|
||||
// 与其他测试的分工:
|
||||
// - internal/gateway/messages_test.go 在**进程内**验证同一件事(起 core+gateway,
|
||||
// 不打真二进制)。它跑得快,但绕过了一些只在真实进程里发生的事:Lua 适配器的
|
||||
// 磁盘 seed、配置加载、systemd 式启动路径、真 HTTP 栈。
|
||||
// - 本文件打**真编译出的二进制**,用一个只说 Anthropic Messages 的假上游,
|
||||
// 证明整条链路(真进程 → 真 Lua 适配器 → 真上游 → 适配器序列化 → 客户端)
|
||||
// 确实成立,而不是只在进程内装配下成立。
|
||||
//
|
||||
// 这是用户明确问到的"适配器接手对外服务的部分"的验收点。
|
||||
|
||||
// anthropicMockUpstream 是只懂 Anthropic Messages 协议的假上游。
|
||||
//
|
||||
// 它会对**不符形状的请求主动报错**,而不是宽容接受 —— 宽容的 mock 会让
|
||||
// "内核把 OpenAI 形状发给了 Anthropic 上游"这种错误测试通过。
|
||||
func anthropicMockUpstream(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Anthropic 的请求路径必须是 <base>/messages
|
||||
if !strings.HasSuffix(r.URL.Path, "/messages") {
|
||||
t.Errorf("请求打到了非 Anthropic 路径: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var probe map[string]interface{}
|
||||
if err := json.Unmarshal(body, &probe); err != nil {
|
||||
t.Errorf("上游收到非 JSON 请求体: %s", body)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Anthropic 形状的硬性要求:messages 是数组;不能有 OpenAI 的 choices 概念
|
||||
if _, ok := probe["messages"]; !ok {
|
||||
t.Errorf("上游请求体缺 messages: %s", body)
|
||||
}
|
||||
if _, ok := probe["max_tokens"]; !ok {
|
||||
t.Errorf("Anthropic 要求 max_tokens,请求体缺此项: %s", body)
|
||||
}
|
||||
// 反向断言:OpenAI 兜底字段不该出现在 Anthropic 上游请求里
|
||||
for _, k := range []string{"choices", "chat_completions"} {
|
||||
if _, bad := probe[k]; bad {
|
||||
t.Errorf("Anthropic 上游请求体混入了 OpenAI 字段 %q: %s", k, body)
|
||||
}
|
||||
}
|
||||
|
||||
if stream, _ := probe["stream"].(bool); stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fl, _ := w.(http.Flusher)
|
||||
frames := []string{
|
||||
`event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-e2e","content":[],"stop_reason":null,"usage":{"input_tokens":3,"output_tokens":0}}}`,
|
||||
`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
|
||||
`event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}`,
|
||||
`event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`,
|
||||
`event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}`,
|
||||
`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
|
||||
}
|
||||
for _, f := range frames {
|
||||
fmt.Fprint(w, f+"\n\n")
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-e2e",`+
|
||||
`"content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn",`+
|
||||
`"usage":{"input_tokens":3,"output_tokens":1}}`)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// writeAnthropicConfig 写一份把源挂到 anthropic 适配器上的配置。
|
||||
func writeAnthropicConfig(t *testing.T, dir, listenAddr, upstreamURL string) string {
|
||||
t.Helper()
|
||||
cfg := "listen: " + listenAddr + "\n" +
|
||||
"gateway_keys:\n - sk-e2e-0001\n" +
|
||||
"default_model: claude-e2e\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"sources:\n" +
|
||||
" - name: anth\n" +
|
||||
" base_url: " + upstreamURL + "\n" +
|
||||
" adapter: anthropic\n" +
|
||||
" api_key: test-key\n" +
|
||||
" max_concurrent: 32\n" +
|
||||
" models:\n" +
|
||||
" - id: claude-e2e\n" +
|
||||
" kind: chat\n" +
|
||||
" priority: 100\n"
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(cfg), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TestAdapterTakeoverNonStream:非流式,Anthropic 客户端形状进、出。
|
||||
func TestAdapterTakeoverNonStream(t *testing.T) {
|
||||
up := anthropicMockUpstream(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
cfg := writeAnthropicConfig(t, dir, addr, up.URL)
|
||||
bin := buildBinary(t)
|
||||
g := startGateway(t, bin, addr, cfg)
|
||||
|
||||
resp, body := g.do("POST", "/v1/messages",
|
||||
`{"model":"claude-e2e","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`, true)
|
||||
if resp == nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("status=%v body=%q", statusOf(resp), body)
|
||||
}
|
||||
// 出向必须是 Anthropic Messages 形状,不能是 OpenAI choices 形状
|
||||
if strings.Contains(body, `"choices"`) {
|
||||
t.Errorf("Anthropic 响应里混入了 OpenAI choices 外壳: %s", body)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Fatalf("响应不是 JSON: %v: %s", err, body)
|
||||
}
|
||||
if out["type"] != "message" {
|
||||
t.Errorf(`响应 type = %v, 期望 "message"(Anthropic 形状): %s`, out["type"], body)
|
||||
}
|
||||
// 文本内容必须能取出来 —— 这是"内容确实穿过适配器回来了"的证据
|
||||
if !strings.Contains(body, "pong") {
|
||||
t.Errorf("响应里没有上游返回的文本: %s", body)
|
||||
}
|
||||
if _, ok := out["usage"]; !ok {
|
||||
t.Errorf("Anthropic 响应应带 usage: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterTakeoverStream:流式也要是 Anthropic SSE 序列,
|
||||
// 且**不得**出现 OpenAI 的 [DONE] 终止标记(R2 的"不混合")。
|
||||
func TestAdapterTakeoverStream(t *testing.T) {
|
||||
up := anthropicMockUpstream(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
cfg := writeAnthropicConfig(t, dir, addr, up.URL)
|
||||
bin := buildBinary(t)
|
||||
g := startGateway(t, bin, addr, cfg)
|
||||
|
||||
resp, body := g.do("POST", "/v1/messages",
|
||||
`{"model":"claude-e2e","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}`, true)
|
||||
if resp == nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("stream status=%v body=%q", statusOf(resp), body)
|
||||
}
|
||||
if !strings.Contains(body, "message_start") {
|
||||
t.Errorf("Anthropic 流应以 message_start 开头: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "message_stop") {
|
||||
t.Errorf("Anthropic 流应以 message_stop 收尾: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "[DONE]") {
|
||||
t.Errorf("Anthropic 流里出现了 OpenAI 的 [DONE]: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "chat.completion.chunk") {
|
||||
t.Errorf("流里混入 OpenAI chunk 形状: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "pong") {
|
||||
t.Errorf("流里没有上游文本: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterTakeoverStress 是对外服务路径的真实压力测试:
|
||||
// 并发打真二进制,混合流式/非流式,验证无 5xx、上游命中数正确、
|
||||
// 且每个响应都是完整合法的 Anthropic 形状(适配器在并发下不串味)。
|
||||
func TestAdapterTakeoverStress(t *testing.T) {
|
||||
up := anthropicMockUpstream(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
cfg := writeAnthropicConfig(t, dir, addr, up.URL)
|
||||
bin := buildBinary(t)
|
||||
g := startGateway(t, bin, addr, cfg)
|
||||
|
||||
// max_concurrent 配成 32,让 96 个请求基本都能真正流过适配器 —— 这个测试
|
||||
// 要压的是**并发下序列化是否串味**,不是内核的容量拒绝策略。真的撞上上限
|
||||
// 时收到的 429 也是合法背压(见下方断言),不算失败。
|
||||
const (
|
||||
workers = 12
|
||||
perWorker = 8
|
||||
)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
ok int
|
||||
shed int
|
||||
failed []string
|
||||
streams int
|
||||
)
|
||||
start := make(chan struct{})
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
for i := 0; i < perWorker; i++ {
|
||||
isStream := (w+i)%2 == 0
|
||||
var payload string
|
||||
if isStream {
|
||||
payload = `{"model":"claude-e2e","max_tokens":32,"stream":true,"messages":[{"role":"user","content":"hi"}]}`
|
||||
} else {
|
||||
payload = `{"model":"claude-e2e","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`
|
||||
}
|
||||
resp, body := g.do("POST", "/v1/messages", payload, true)
|
||||
mu.Lock()
|
||||
switch {
|
||||
case resp == nil:
|
||||
failed = append(failed, "nil response")
|
||||
// 429 = 内核的容量背压(provider busy)。它是**正确**行为,
|
||||
// 不是缺陷:并发超限时就该如实拒绝,而不是排队或伪造响应。
|
||||
// 只要求它是结构良好的错误,不要求 200。
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
if !strings.Contains(body, "busy") && !strings.Contains(body, "error") {
|
||||
failed = append(failed, "429 不是结构化错误: "+body)
|
||||
} else {
|
||||
shed++
|
||||
}
|
||||
// 5xx 才是真失败 —— 那是内核或适配器崩了。
|
||||
case resp.StatusCode >= 500:
|
||||
failed = append(failed, fmt.Sprintf("5xx status=%d body=%.120s", resp.StatusCode, body))
|
||||
case resp.StatusCode != 200:
|
||||
failed = append(failed, fmt.Sprintf("status=%d body=%.120s", resp.StatusCode, body))
|
||||
case !strings.Contains(body, "pong"):
|
||||
// 200 但内容不对:说明并发下适配器把响应串味了
|
||||
failed = append(failed, "missing upstream text: "+body)
|
||||
case isStream && !strings.Contains(body, "message_stop"):
|
||||
failed = append(failed, "stream missing message_stop")
|
||||
case !isStream && strings.Contains(body, `"choices"`):
|
||||
failed = append(failed, "openai shell leaked: "+body)
|
||||
default:
|
||||
ok++
|
||||
if isStream {
|
||||
streams++
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if len(failed) > 0 {
|
||||
t.Fatalf("%d/%d 请求失败,前 5 例:\n%s", len(failed), workers*perWorker,
|
||||
strings.Join(failed[:min(5, len(failed))], "\n"))
|
||||
}
|
||||
// 至少大部分请求必须真正流过 200 —— 全被背压拒掉说明并发上限配得太低,
|
||||
// 那这个压力测试就没压到适配器并发。
|
||||
if ok < workers*perWorker/2 {
|
||||
t.Fatalf("只有 %d/%d 请求真正成功,压力未触及适配器并发路径",
|
||||
ok, workers*perWorker)
|
||||
}
|
||||
t.Logf("成功 %d(其中流式 %d),背压 429 %d —— 无 5xx、无 OpenAI 形状泄漏",
|
||||
ok, streams, shed)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
var _ = time.Second
|
||||
@ -1,261 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeE2EPlugin 落一个插件文件到 e2e 的 plugin_dir(SeedBundled 见空目录才
|
||||
// 落地内置插件,所以先放自定义插件再启动网关)。
|
||||
func writeE2EPlugin(t *testing.T, dir, name, code string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, "plugins", name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(code), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// e2eConfig 返回一份最小 e2e 配置文本(含 plugin_dir)。
|
||||
func e2eConfig(dir, listen string) string {
|
||||
return "listen: " + listen + "\n" +
|
||||
"gateway_keys:\n - sk-e2e-0001\n" +
|
||||
"default_model: AUTO\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"sources: []\n"
|
||||
}
|
||||
|
||||
// TestConflictArbitrationE2E(用户要求:服务路径冲突不崩溃不拒启 + WebAPI 裁决)
|
||||
//
|
||||
// 真二进制全链路:
|
||||
// 1. 两个插件声明同一条 /api/plugins/http/ 路径 → 两插件都 loaded,启动正常
|
||||
// 2. 请求该路径 → 409,错误信息点名两个候选插件(不静默选边)
|
||||
// 3. GET /api/plugins/conflicts → 列出冲突(路径 + 候选 + 未裁决)
|
||||
// 4. POST /api/plugins/conflicts 裁决 → 持久化
|
||||
// 5. 再请求该路径 → 200,赢家应答({"from":"B"})
|
||||
// 6. /v1/models 等既有路由全程不受影响(旧通路无回归)
|
||||
func TestConflictArbitrationE2E(t *testing.T) {
|
||||
bin := buildBinary(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
|
||||
writeE2EPlugin(t, dir, "plugA.lua", `
|
||||
local P = {}
|
||||
P.name = "plugA"
|
||||
P.version = "1"
|
||||
P.exports = {}
|
||||
P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } }
|
||||
function P.h(req) return { status = 200, json = { from = "A" } } end
|
||||
return P
|
||||
`)
|
||||
writeE2EPlugin(t, dir, "plugB.lua", `
|
||||
local P = {}
|
||||
P.name = "plugB"
|
||||
P.version = "1"
|
||||
P.exports = {}
|
||||
P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } }
|
||||
function P.h(req) return { status = 200, json = { from = "B" } } end
|
||||
return P
|
||||
`)
|
||||
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := startGateway(t, bin, addr, cfgPath)
|
||||
|
||||
// ① 两个插件都加载成功(冲突不拒载)
|
||||
resp, body := g.do(http.MethodGet, "/api/plugins", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /api/plugins: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var list struct {
|
||||
Plugins []struct {
|
||||
Name string `json:"name"`
|
||||
Loaded bool `json:"loaded"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded := map[string]bool{}
|
||||
for _, p := range list.Plugins {
|
||||
loaded[p.Name] = p.Loaded
|
||||
}
|
||||
if !loaded["plugA"] || !loaded["plugB"] {
|
||||
t.Fatalf("冲突的两个插件都应 loaded: %v", loaded)
|
||||
}
|
||||
|
||||
// ② 请求冲突路径 → 409 + 点名候选(不静默选边)
|
||||
resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("未裁决冲突路径应 409, 实得 %d (%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(body, "plugA") || !strings.Contains(body, "plugB") {
|
||||
t.Fatalf("409 应点名两个候选: %s", body)
|
||||
}
|
||||
|
||||
// ③ 冲突列表可见
|
||||
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var cl struct {
|
||||
Routes []struct {
|
||||
Path string `json:"path"`
|
||||
Candidates []string `json:"candidates"`
|
||||
Resolved string `json:"resolved"`
|
||||
} `json:"routes"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &cl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cl.Routes) != 1 || cl.Routes[0].Path != "contested" || cl.Routes[0].Resolved != "" {
|
||||
t.Fatalf("冲突形态不对: %+v", cl)
|
||||
}
|
||||
|
||||
// ④ 裁决 plugB 赢
|
||||
arbit, _ := json.Marshal(map[string]string{
|
||||
"kind": "route", "path": "contested", "winner": "plugB",
|
||||
})
|
||||
resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("POST conflicts 裁决: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// ⑤ 再请求 → 200 且赢家应答
|
||||
resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("裁决后请求冲突路径应 200, 实得 %d (%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(body, `"from":"B"`) {
|
||||
t.Fatalf("赢家 plugB 应答, 实得 %s", body)
|
||||
}
|
||||
|
||||
// ⑥ 旧通路无回归:/v1/models 正常
|
||||
resp, _ = g.do(http.MethodGet, "/v1/models", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("旧通路 /v1/models 应 200, 实得 %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterConflictE2E:插件装与内置同名适配器 → 挂起(现任保留),
|
||||
// /api/plugins/conflicts 列出,裁决 candidate 后插件版生效。
|
||||
func TestAdapterConflictE2E(t *testing.T) {
|
||||
bin := buildBinary(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
|
||||
writeE2EPlugin(t, dir, "shadow.lua", `
|
||||
local P = {}
|
||||
P.name = "shadow"
|
||||
P.version = "1"
|
||||
P.exports = { "install" }
|
||||
function P.install(args)
|
||||
local src = 'local a={} a.name="openai" a.version="SHADOW_V1" a.endpoint="/x" return a'
|
||||
local name, err = __llmsproxy_adapters.install("openai", src)
|
||||
return { name = name, err = err }
|
||||
end
|
||||
return P
|
||||
`)
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := startGateway(t, bin, addr, cfgPath)
|
||||
|
||||
// ① 安装 → 返回 err(pending 语义),内置版本不变
|
||||
resp, body := g.do(http.MethodPost, "/api/plugins/shadow/call/install", "{}", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("call install: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
// Call 端点把插件返回值包在 {"result": …} 里;挂起语义的断言在 result 层做。
|
||||
var wrapped struct {
|
||||
Result struct {
|
||||
Name *string `json:"name"`
|
||||
Err string `json:"err"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &wrapped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := wrapped.Result
|
||||
if out.Name != nil {
|
||||
t.Fatalf("挂起时不得返回生效名: %s", body)
|
||||
}
|
||||
if !strings.Contains(out.Err, "openai") || !strings.Contains(out.Err, "conflict") {
|
||||
t.Fatalf("错误应点名冲突: %q", out.Err)
|
||||
}
|
||||
|
||||
// ② 冲突列表可见
|
||||
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var cl struct {
|
||||
Adapters []struct {
|
||||
Name string `json:"name"`
|
||||
Candidate string `json:"candidate"`
|
||||
} `json:"adapters"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &cl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cl.Adapters) != 1 || cl.Adapters[0].Name != "openai" || cl.Adapters[0].Candidate != "shadow" {
|
||||
t.Fatalf("冲突形态不对: %+v", cl)
|
||||
}
|
||||
|
||||
// ③ 适配器列表里 openai 仍是内置版本(现任继续服务,不被静默覆盖)
|
||||
assertOpenAIVersion := func(want string, msg string) {
|
||||
t.Helper()
|
||||
resp, body := g.do(http.MethodGet, "/api/adapters", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /api/adapters: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var al struct {
|
||||
Adapters []struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
} `json:"adapters"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &al); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range al.Adapters {
|
||||
if a.Name == "openai" {
|
||||
if a.Version != want {
|
||||
t.Fatalf("%s: openai 版本应 %q, 实得 %q", msg, want, a.Version)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s: openai 适配器不存在: %s", msg, body)
|
||||
}
|
||||
assertOpenAIVersion("2.0.0", "现任内置必须继续服务(不被静默覆盖)")
|
||||
|
||||
// ④ 裁决 candidate → 插件版生效
|
||||
arbit, _ := json.Marshal(map[string]string{
|
||||
"kind": "adapter", "name": "openai", "winner": "candidate",
|
||||
})
|
||||
if resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true); resp.StatusCode != 200 {
|
||||
t.Fatalf("裁决 candidate: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// ⑤ 适配器版本已切换 + 冲突清空
|
||||
assertOpenAIVersion("SHADOW_V1", "裁决后插件版应生效")
|
||||
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatal(resp.StatusCode)
|
||||
}
|
||||
if strings.Contains(body, `"name":"openai"`) && strings.Contains(body, `"adapters":[{`) {
|
||||
t.Fatalf("裁决后冲突应清除: %s", body)
|
||||
}
|
||||
}
|
||||
157
e2e/e2e_test.go
157
e2e/e2e_test.go
@ -1,4 +1,4 @@
|
||||
// Package e2e runs the real modelrouter binary against mocked upstreams over
|
||||
// Package e2e runs the real llmsproxy binary against mocked upstreams over
|
||||
// real HTTP: startup, auth, chat, streaming SSE, image generation, and
|
||||
// AUTO failover when a higher-priority source fails.
|
||||
package e2e
|
||||
@ -100,15 +100,15 @@ type gatewayUnderTest struct {
|
||||
func buildBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "modelrouter")
|
||||
bin := filepath.Join(dir, "llmsproxy")
|
||||
if runtime.GOOS == "windows" {
|
||||
bin += ".exe" // go build writes the exact name; Windows needs the suffix to exec
|
||||
}
|
||||
out, err := exec.Command("go", "build", "-tags", "luajit", "-o", bin, "modelrouter/cmd/modelrouter").CombinedOutput()
|
||||
out, err := exec.Command("go", "build", "-tags", "luajit", "-o", bin, "llmsproxy/cmd/llmsproxy").CombinedOutput()
|
||||
if err != nil && runtime.GOOS == "windows" {
|
||||
// local Windows dev box may lack LuaJIT: fall back to the bundled
|
||||
// Lua runtime (the e2e adapters used here are passthrough-only)
|
||||
out, err = exec.Command("go", "build", "-o", bin, "modelrouter/cmd/modelrouter").CombinedOutput()
|
||||
out, err = exec.Command("go", "build", "-o", bin, "llmsproxy/cmd/llmsproxy").CombinedOutput()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v\n%s", err, out)
|
||||
@ -187,20 +187,18 @@ func writeConfig(t *testing.T, dir, listenAddr string, upstreams map[string]*moc
|
||||
sb.WriteString("runtime_file: " + filepath.Join(dir, "runtime.json") + "\n")
|
||||
sb.WriteString("sources:\n")
|
||||
order := []string{"good", "fallback", "image"}
|
||||
prio := map[string]int{"good": 100, "fallback": 50}
|
||||
for _, name := range order {
|
||||
u, ok := upstreams[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name == "image" {
|
||||
sb.WriteString(" - name: imagegen\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: flux-1\n kind: image\n")
|
||||
sb.WriteString(" - name: imagegen\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: flux-1\n kind: image\n priority: 80\n")
|
||||
} else {
|
||||
sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n")
|
||||
sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n priority: " + fmt.Sprint(prio[name]) + "\n")
|
||||
}
|
||||
}
|
||||
// AUTO 链现在是显式配置(25970a2 移除了 models.priority)。tier 越小越先试。
|
||||
sb.WriteString("auto:\n - model: good-m\n source: good\n tier: 1\n")
|
||||
sb.WriteString(" - model: fallback-m\n source: fallback\n tier: 2\n")
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
@ -328,144 +326,3 @@ func statusOf(resp *http.Response) int {
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// The startup warning about key configuration must describe the key set that
|
||||
// AUTH actually uses (cfg.Keys, via core.ListKeys), not the legacy
|
||||
// gateway_keys YAML list.
|
||||
//
|
||||
// It used to check GatewayKeys() only. seedKeys copies gateway_keys into keys[]
|
||||
// on first start and the YAML list stops being consulted, so once that list was
|
||||
// emptied — e.g. after rotating the starter key away — the process logged
|
||||
// "every request will be rejected" while seven working keys, one of them admin,
|
||||
// were in service. Observed in production: the warning appeared and that same
|
||||
// key returned HTTP 200 on /v1/models.
|
||||
//
|
||||
// The fixture reproduces exactly that shape: gateway_keys EMPTY, with the
|
||||
// working key present only in keys[].
|
||||
func TestStartupWarningReflectsRealKeysNotLegacyList(t *testing.T) {
|
||||
up := newMockUpstream(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
// Seed a real install once so keys[] gets populated from gateway_keys, then
|
||||
// rewrite the file to look like a rotated install: legacy list cleared, the
|
||||
// seeded key still present in keys[].
|
||||
path := writeConfig(t, dir, "127.0.0.1:0", map[string]*mockUpstream{"good": up})
|
||||
bin := buildBinary(t)
|
||||
|
||||
// First run creates the sealed config with keys[] populated.
|
||||
seedPort := freePort(t)
|
||||
cfg1 := writeConfig(t, dir, "127.0.0.1:"+seedPort, map[string]*mockUpstream{"good": up})
|
||||
g1 := startGateway(t, bin, "127.0.0.1:"+seedPort, cfg1)
|
||||
g1.stop()
|
||||
|
||||
data, err := os.ReadFile(cfg1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "keys:") {
|
||||
t.Fatalf("first run did not populate keys[]:\n%s", data)
|
||||
}
|
||||
|
||||
// Now clear the legacy gateway_keys list, keeping keys[] intact. The
|
||||
// rewritten file may use different indentation, so match loosely.
|
||||
cleared, n := clearGatewayKeys(string(data))
|
||||
if n == 0 {
|
||||
t.Fatalf("could not find a gateway_keys entry to clear in:\n%s", data)
|
||||
}
|
||||
if err := os.WriteFile(cfg1, []byte(cleared), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Start again and inspect the log. The config pins its listen address, so
|
||||
// rewrite that field to the port we will actually poll.
|
||||
port2 := freePort(t)
|
||||
rehosted, ok := replaceListen(string(cleared), "127.0.0.1:"+port2)
|
||||
if !ok {
|
||||
t.Fatalf("could not rewrite listen in:\n%s", cleared)
|
||||
}
|
||||
cfg2 := filepath.Join(dir, "config2.yaml")
|
||||
if err := os.WriteFile(cfg2, []byte(rehosted), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g2 := startGateway(t, bin, "127.0.0.1:"+port2, cfg2)
|
||||
defer g2.stop()
|
||||
|
||||
// Sanity: the key really does authenticate, so the warning would be a lie.
|
||||
resp, _ := g2.do("GET", "/v1/models", "", true)
|
||||
if resp == nil || resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("key from keys[] did not authenticate (status %v); test fixture is wrong",
|
||||
resp)
|
||||
}
|
||||
|
||||
logs := g2.logString()
|
||||
if strings.Contains(logs, "gateway_keys is EMPTY") {
|
||||
t.Errorf("startup logged 'gateway_keys is EMPTY ... every request is rejected' while "+
|
||||
"a working admin key was in service — the warning must read the authoritative "+
|
||||
"key set (core.ListKeys), not the legacy YAML list.\nlog:\n%s", logs)
|
||||
}
|
||||
_ = path
|
||||
_ = g1
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
_, port, _ := net.SplitHostPort(l.Addr().String())
|
||||
return port
|
||||
}
|
||||
|
||||
// stop terminates the gateway and waits, so its log buffer is complete.
|
||||
func (g *gatewayUnderTest) stop() {
|
||||
if g == nil || g.cmd == nil || g.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = g.cmd.Process.Kill()
|
||||
_, _ = g.cmd.Process.Wait()
|
||||
}
|
||||
|
||||
// logString returns everything the gateway has written so far.
|
||||
func (g *gatewayUnderTest) logString() string { return g.log.String() }
|
||||
|
||||
// clearGatewayKeys empties the gateway_keys list in a config, tolerating any
|
||||
// indentation the YAML writer chose. Returns the new text and how many entries
|
||||
// it removed.
|
||||
func clearGatewayKeys(src string) (string, int) {
|
||||
lines := strings.Split(src, "\n")
|
||||
var out []string
|
||||
removed := 0
|
||||
inList := false
|
||||
for _, l := range lines {
|
||||
trimmed := strings.TrimSpace(l)
|
||||
if strings.HasPrefix(l, "gateway_keys:") {
|
||||
out = append(out, "gateway_keys: []")
|
||||
inList = true
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
if inList {
|
||||
if strings.HasPrefix(trimmed, "- ") {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
inList = false
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return strings.Join(out, "\n"), removed
|
||||
}
|
||||
|
||||
// replaceListen rewrites the top-level listen: value.
|
||||
func replaceListen(src, addr string) (string, bool) {
|
||||
lines := strings.Split(src, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "listen:") {
|
||||
lines[i] = "listen: " + addr
|
||||
return strings.Join(lines, "\n"), true
|
||||
}
|
||||
}
|
||||
return src, false
|
||||
}
|
||||
|
||||
@ -1,192 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mustAddr 选一个空闲端口并立即释放(与 e2e_test.go 的做法一致)。
|
||||
func mustAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("pick port: %v", err)
|
||||
}
|
||||
addr := l.Addr().String()
|
||||
l.Close()
|
||||
return addr
|
||||
}
|
||||
|
||||
// 本文件建立**字节级**对外输出基线,供统一线格式 v2(对外序列化移交适配器)
|
||||
// 做回归比对。
|
||||
//
|
||||
// 为什么必须有它:Phase 2 要把 gateway/chat.go 里硬编码的 OpenAI wire 结构与
|
||||
// writeChatCompletion/pumpStream 的 JSON 拼装移交给适配器 serializer。现有 e2e
|
||||
// 只用 strings.Contains 做宽松断言("包含 Hel"),无法证明"输出字节未变"——
|
||||
// 字段集、omitempty 裁剪、usage 零值处理都可能静默漂移,而客户端会因此看到
|
||||
// 不同的 wire 形状。golden 把当前输出钉死,改动前后逐字节比对。
|
||||
//
|
||||
// 更新方式:GOLDEN_UPDATE=1 go test -tags luajit ./e2e/ -run TestWireGolden
|
||||
// 更新后必须人工审阅 diff —— golden 是契约,不是快照垃圾桶。
|
||||
|
||||
// volatile 命中每次请求都变的字段,比对前替换为占位符。
|
||||
// 允许冒号后的空白:golden 是 pretty-print 过的(`"created": 123`)。
|
||||
var volatile = []*regexp.Regexp{
|
||||
regexp.MustCompile(`"id":\s*"[^"]*"`),
|
||||
regexp.MustCompile(`"created":\s*\d+`),
|
||||
}
|
||||
|
||||
func scrub(s string) string {
|
||||
s = volatile[0].ReplaceAllString(s, `"id": "<ID>"`)
|
||||
return volatile[1].ReplaceAllString(s, `"created": <TS>`)
|
||||
}
|
||||
|
||||
func compareGolden(t *testing.T, name, got string) {
|
||||
t.Helper()
|
||||
got = scrub(got)
|
||||
path := filepath.Join("..", ".snapshots", name)
|
||||
if os.Getenv("GOLDEN_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("golden updated: %s", path)
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("golden missing (%s): %v\nrun GOLDEN_UPDATE=1 go test -tags luajit ./e2e/ -run TestWireGolden to create", path, err)
|
||||
}
|
||||
if got != string(want) {
|
||||
t.Errorf("对外输出与 golden 漂移 (%s)\n--- want ---\n%s\n--- got ---\n%s",
|
||||
name, string(want), got)
|
||||
}
|
||||
}
|
||||
|
||||
// prettyJSON 缩进输出。走 map 会排序键,从而让「字段集」成为唯一被比较的东西,
|
||||
// 忽略 Go struct 声明顺序(那不是对外契约的一部分)。
|
||||
func prettyJSON(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
return raw
|
||||
}
|
||||
out, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// TestWireGoldenNonStreamChat 钉死非流式 /v1/chat/completions 的字段集。
|
||||
func TestWireGoldenNonStreamChat(t *testing.T) {
|
||||
up := newMockUpstream(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"good": up}))
|
||||
|
||||
resp, body := g.do("POST", "/v1/chat/completions",
|
||||
`{"model":"good/good-m","messages":[{"role":"user","content":"hi"}]}`, true)
|
||||
|
||||
// 语义:OpenAI 客户端依赖这些顶层字段
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(body), &obj); err != nil {
|
||||
t.Fatalf("not JSON: %v (%s)", err, body)
|
||||
}
|
||||
for _, k := range []string{"id", "object", "created", "model", "choices", "usage"} {
|
||||
if _, ok := obj[k]; !ok {
|
||||
t.Errorf("非流式响应缺少 OpenAI 必需字段 %q: %s", k, body)
|
||||
}
|
||||
}
|
||||
if obj["object"] != "chat.completion" {
|
||||
t.Errorf("object = %v, want chat.completion", obj["object"])
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
compareGolden(t, "chat_nonstream.json", prettyJSON(t, body))
|
||||
}
|
||||
|
||||
// TestWireGoldenStreamChat 钉死流式 SSE 的帧形状与终止序列。
|
||||
func TestWireGoldenStreamChat(t *testing.T) {
|
||||
up := newMockUpstream(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"good": up}))
|
||||
|
||||
resp, _ := doStream(t, g, `{"model":"good/good-m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var frames []string
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimPrefix(line, "data: ")
|
||||
if payload == "[DONE]" {
|
||||
frames = append(frames, "[DONE]")
|
||||
continue
|
||||
}
|
||||
frames = append(frames, prettyJSON(t, payload))
|
||||
}
|
||||
if len(frames) < 2 {
|
||||
t.Fatalf("期望多帧 SSE,实得 %d: %v", len(frames), frames)
|
||||
}
|
||||
if frames[len(frames)-1] != "[DONE]" {
|
||||
t.Errorf("末帧 = %q, want [DONE]", frames[len(frames)-1])
|
||||
}
|
||||
// 首帧必须是 role 前导:OpenAI 客户端靠它建立 assistant 消息
|
||||
if !strings.Contains(frames[0], `"role"`) {
|
||||
t.Errorf("首个 SSE 帧必须携带 role 前导,实得: %s", frames[0])
|
||||
}
|
||||
|
||||
compareGolden(t, "chat_stream.txt", strings.Join(frames, "\n"))
|
||||
}
|
||||
|
||||
// TestWireGoldenImage 钉死 /v1/images/generations 的响应形状。
|
||||
func TestWireGoldenImage(t *testing.T) {
|
||||
up := newMockUpstream(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"image": up}))
|
||||
|
||||
resp, body := g.do("POST", "/v1/images/generations", `{"model":"imagegen/flux-1","prompt":"a cat"}`, true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("image status = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(body, "QUJD") {
|
||||
t.Fatalf("生图响应丢失了载荷: %s", body)
|
||||
}
|
||||
compareGolden(t, "image_generation.json", prettyJSON(t, body))
|
||||
}
|
||||
|
||||
// doStream 发流式请求并返回未读完的响应(调用方负责逐行读 SSE)。
|
||||
func doStream(t *testing.T, g *gatewayUnderTest, body string) (*http.Response, error) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest("POST", "http://"+g.addr+"/v1/chat/completions", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+g.key)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("stream request: %v", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
var _ = io.Discard
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@ -20,7 +19,6 @@ type Config struct {
|
||||
GatewayKeys []string `yaml:"gateway_keys"`
|
||||
DefaultModel string `yaml:"default_model"` // e.g. "AUTO" or a model id
|
||||
AdapterDir string `yaml:"adapter_dir"`
|
||||
PluginDir string `yaml:"plugin_dir,omitempty"` // Lua plugin directory (optional; hooks + WebUI extensions)
|
||||
RuntimeFile string `yaml:"runtime_file"`
|
||||
TLSCertFile string `yaml:"tls_cert_file,omitempty"` // PEM cert; when set together with tls_key_file, serve HTTPS
|
||||
TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key
|
||||
@ -29,20 +27,6 @@ type Config struct {
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
// PluginVisibility overrides the audience a plugin declared for itself,
|
||||
// keyed by plugin name: "admin" hides a plugin (and its state) from user
|
||||
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
|
||||
// explicitly. An absent name leaves the plugin's own declaration in force.
|
||||
//
|
||||
// This exists because a plugin author cannot know the operator's policy, and
|
||||
// the operator should not have to edit a bundled .lua to change one — the
|
||||
// next upgrade overwrites it.
|
||||
PluginVisibility map[string]string `yaml:"plugin_visibility,omitempty" json:"plugin_visibility,omitempty"`
|
||||
// box seals credentials (sources' api_key/headers, keys' key) at rest.
|
||||
// In-memory values are always plaintext; only the bytes on disk are sealed.
|
||||
// Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay
|
||||
// filesystem-free.
|
||||
box *SecretBox
|
||||
}
|
||||
|
||||
// Defaults applied to any source (YAML or runtime) that leaves a field unset.
|
||||
@ -52,47 +36,30 @@ const (
|
||||
DefaultSourceConcurrency = 8
|
||||
)
|
||||
|
||||
// Model is a single exposed model id bound to a source.
|
||||
//
|
||||
// ★ 曾经有 `Priority int` 字段,并号称"高者优先参与 AUTO 选源"。**那是错的**:
|
||||
//
|
||||
// AUTO 链由 `config.ModelScope.Tier` 驱动(见 scheduler.Chain),而 priority
|
||||
// 只被 `bestChatModel/bestImageModel` 用于"本源码内部该发哪个模型",
|
||||
// 与跨源调度毫无关系。把它当成调度依据会让人误以为改个数字就能改顺序,
|
||||
// 改完发现毫无效果 —— 已彻底删除,避免再次误导。
|
||||
// Model is a single exposed model id bound to a source, with priority used by
|
||||
// AUTO auto selection (higher number = preferred).
|
||||
type Model struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
|
||||
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Priority int `yaml:"priority" json:"priority"`
|
||||
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
|
||||
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Source describes a single upstream LLM provider.
|
||||
type Source struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
APIKey string `yaml:"api_key" json:"api_key"`
|
||||
APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key)
|
||||
Adapter string `yaml:"adapter" json:"adapter"`
|
||||
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
|
||||
// StreamEndpoint overrides the CHAT endpoint for streaming requests. Most
|
||||
// upstreams stream on the same path; Gemini is the exception (same path,
|
||||
// different verb). Declaring it here keeps that knowledge out of the kernel,
|
||||
// which only compares opaque strings.
|
||||
StreamEndpoint string `yaml:"endpoint_stream" json:"endpoint_stream,omitempty"`
|
||||
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override
|
||||
Models []Model `yaml:"models" json:"models"`
|
||||
Headers map[string]string `yaml:"headers" json:"headers,omitempty"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
APIKey string `yaml:"api_key" json:"api_key"`
|
||||
APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key)
|
||||
Adapter string `yaml:"adapter" json:"adapter"`
|
||||
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
|
||||
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override
|
||||
Models []Model `yaml:"models" json:"models"`
|
||||
Headers map[string]string `yaml:"headers" json:"headers,omitempty"`
|
||||
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
|
||||
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
|
||||
// upstreams behind geo/IP blocks that only respond through a proxy.
|
||||
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
|
||||
// Retry is an OPTIONAL upstream retry policy. It exists for upstreams that
|
||||
// answer a business-level rejection with a status code the kernel would
|
||||
// otherwise surface to the client — e.g. a per-account concurrency cap that
|
||||
// clears by itself after tens of seconds. The kernel stays vendor-agnostic:
|
||||
// the matcher is pure DATA (status list + response-body substrings), so no
|
||||
// provider name ever appears in core.
|
||||
Retry *RetryPolicy `yaml:"retry" json:"retry,omitempty"`
|
||||
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
|
||||
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
||||
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
|
||||
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
|
||||
@ -102,66 +69,9 @@ type Source struct {
|
||||
RPM int `yaml:"rpm" json:"rpm,omitempty"` // optional requests-per-minute cap (0 = unlimited)
|
||||
}
|
||||
|
||||
// RetryPolicy describes when a failed upstream attempt may be retried BEFORE
|
||||
// anything is returned to the client. All conditions are data:
|
||||
//
|
||||
// OnStatus - retry only when the HTTP status is one of these
|
||||
// OnBodyContains - ... AND the response body contains one of these substrings
|
||||
// (empty = status alone decides)
|
||||
// Delay - wait between attempts ("10s" etc.); parsed on demand
|
||||
// MaxAttempts - TOTAL attempts including the first (1 or 0 = no retry)
|
||||
//
|
||||
// Scope: the retry happens inside one gateway request, so a stream is only
|
||||
// retried before its first chunk reaches the client (the kernel already holds
|
||||
// the first chunk back), and a non-stream call is retried as a whole.
|
||||
type RetryPolicy struct {
|
||||
OnStatus []int `yaml:"on_status,omitempty" json:"on_status,omitempty"`
|
||||
OnBodyContains []string `yaml:"on_body_contains,omitempty" json:"on_body_contains,omitempty"`
|
||||
Delay string `yaml:"delay,omitempty" json:"delay,omitempty"`
|
||||
MaxAttempts int `yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"`
|
||||
}
|
||||
|
||||
// ShouldRetry reports whether one failed attempt matches this policy.
|
||||
func (r *RetryPolicy) ShouldRetry(status int, body string) bool {
|
||||
if r == nil || r.MaxAttempts <= 1 {
|
||||
return false
|
||||
}
|
||||
statusOK := len(r.OnStatus) == 0
|
||||
for _, s := range r.OnStatus {
|
||||
if s == status {
|
||||
statusOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !statusOK {
|
||||
return false
|
||||
}
|
||||
if len(r.OnBodyContains) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, sub := range r.OnBodyContains {
|
||||
if sub != "" && strings.Contains(body, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DelayDuration is the configured pause between attempts (default 10s).
|
||||
func (r *RetryPolicy) DelayDuration() time.Duration {
|
||||
if r == nil || r.Delay == "" {
|
||||
return 10 * time.Second
|
||||
}
|
||||
d, err := time.ParseDuration(r.Delay)
|
||||
if err != nil || d < 0 {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Load reads and validates a config file. When the file does not exist yet a
|
||||
// default config is generated at that path first (first-run bootstrap), so a
|
||||
// fresh binary just works: `modelrouter -config /path/to/config.yaml`.
|
||||
// fresh binary just works: `llmsproxy -config /path/to/config.yaml`.
|
||||
func Load(path string) (*Config, error) {
|
||||
if _, err := EnsureDefault(path); err != nil {
|
||||
return nil, err
|
||||
@ -217,7 +127,6 @@ func writeDefaultConfig(path string) error {
|
||||
GatewayKeys: []string{key},
|
||||
DefaultModel: "AUTO",
|
||||
AdapterDir: filepath.Join(abs, "adapters"),
|
||||
PluginDir: filepath.Join(abs, "plugins"),
|
||||
RuntimeFile: filepath.Join(abs, "runtime.json"),
|
||||
}
|
||||
out, err := yaml.Marshal(&cfg)
|
||||
@ -283,15 +192,10 @@ func RemoveSourceFromYAML(path, name string) error {
|
||||
|
||||
// UpsertSourceInYAML adds or updates a source entry in the YAML config file.
|
||||
// Uses yaml.Node to preserve the rest of the file's comments and formatting.
|
||||
// When box is non-nil the source's credentials are sealed before writing, so a
|
||||
// newly added source never lands in the file as plaintext.
|
||||
func UpsertSourceInYAML(path, name string, src Source, box *SecretBox) error {
|
||||
func UpsertSourceInYAML(path, name string, src Source) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("config path is empty")
|
||||
}
|
||||
if err := sealSource(&src, box); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -349,17 +253,11 @@ func UpsertSourceInYAML(path, name string, src Source, box *SecretBox) error {
|
||||
|
||||
// Save writes the current config back to the YAML file (preserving comments
|
||||
// via yaml.Node round-trip when possible, or full marshaling as fallback).
|
||||
// Credentials are sealed on the way out and the in-memory copy is restored to
|
||||
// plaintext afterwards, so callers keep working with usable values.
|
||||
func (c *Config) Save() error {
|
||||
if c.Path == "" {
|
||||
return fmt.Errorf("config path is empty")
|
||||
}
|
||||
if err := c.sealInPlace(c.box); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := yaml.Marshal(c)
|
||||
c.unsealAfterWrite(c.box)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
@ -374,9 +272,6 @@ func (c *Config) ApplyDefaults() error {
|
||||
if c.AdapterDir == "" {
|
||||
c.AdapterDir = "adapters"
|
||||
}
|
||||
// PluginDir is deliberately NOT defaulted: with no plugins a missing
|
||||
// directory would be created and seeded on every start for nothing. An
|
||||
// operator opts in by setting it (or by dropping files into it).
|
||||
if c.RuntimeFile == "" {
|
||||
c.RuntimeFile = "runtime.json"
|
||||
}
|
||||
@ -437,14 +332,13 @@ type RuntimeConfig struct {
|
||||
// name and api_key) so the WebUI can spin up multiple key-bearing sources
|
||||
// from one shared template.
|
||||
type SourceTemplate struct {
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Adapter string `json:"adapter"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
StreamEndpoint string `json:"endpoint_stream,omitempty"`
|
||||
ImageEndpoint string `json:"image_endpoint,omitempty"`
|
||||
Models []Model `json:"models"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Adapter string `json:"adapter"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
ImageEndpoint string `json:"image_endpoint,omitempty"`
|
||||
Models []Model `json:"models"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy.
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
Meta map[string]interface{} `json:"meta,omitempty"`
|
||||
@ -458,95 +352,24 @@ type SourceTemplate struct {
|
||||
// (full management) or "user" (sees only its own key); Models is the allowed
|
||||
// model scope with per-model token quota (0 = unlimited).
|
||||
type GWKey struct {
|
||||
Key string `yaml:"key" json:"key"`
|
||||
Role string `yaml:"role" json:"role"`
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
// Auto is this key's own AUTO scheduling chain. It overrides the global
|
||||
// cfg.Auto for requests authenticated with this key; nil (or an empty
|
||||
// list) means "inherit the global chain", so keys created before per-key
|
||||
// chains existed keep working untouched.
|
||||
Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"`
|
||||
Key string `yaml:"key" json:"key"`
|
||||
Role string `yaml:"role" json:"role"`
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
|
||||
Note string `yaml:"note,omitempty" json:"note,omitempty"`
|
||||
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
|
||||
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
|
||||
}
|
||||
|
||||
// HasOwnAuto reports whether the key declares its own AUTO chain rather than
|
||||
// inheriting the global one. An explicitly empty list still counts as "no own
|
||||
// chain" so a user can clear an override back to the global default.
|
||||
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
|
||||
|
||||
// KeyQuota is retained only to carry a scope entry's caps through the admin
|
||||
// API. Quotas are per model, never per key: there is deliberately no key-wide
|
||||
// total, so exhausting one model's budget never blocks the others.
|
||||
type KeyQuota struct {
|
||||
TokenQuota int64 `json:"token_quota"`
|
||||
ReqQuota int64 `json:"req_quota"`
|
||||
Period string `json:"period"`
|
||||
Hours int64 `json:"hours"`
|
||||
}
|
||||
|
||||
// NormalizeRole defaults an empty role to "user", so a key can never end up in
|
||||
// a state where no role means "neither admin nor user".
|
||||
func NormalizeRole(role string) string {
|
||||
if role == "admin" {
|
||||
return "admin"
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
|
||||
// Validate rejects a quota configuration that could not work as written. A
|
||||
// period is only meaningful when at least one cap is set, and a cap of zero
|
||||
// means "unlimited" rather than "deny everything", so those are the only two
|
||||
// things worth rejecting.
|
||||
func (q KeyQuota) Validate() error {
|
||||
if q.TokenQuota < 0 {
|
||||
return fmt.Errorf("token_quota must be >= 0 (0 = unlimited)")
|
||||
}
|
||||
if q.ReqQuota < 0 {
|
||||
return fmt.Errorf("req_quota must be >= 0 (0 = unlimited)")
|
||||
}
|
||||
if q.Hours < 0 {
|
||||
return fmt.Errorf("hours must be >= 0")
|
||||
}
|
||||
if q.TokenQuota > 0 || q.ReqQuota > 0 {
|
||||
if err := ValidatePeriod(q.Period, q.Hours); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePeriod accepts the quota period vocabulary: "" (never resets),
|
||||
// "hour", "week", "month", or "nhour" with hours >= 1. An unknown period is
|
||||
// rejected rather than silently treated as "never resets", which would turn a
|
||||
// typo into an all-time quota — the opposite of what the operator typed.
|
||||
func ValidatePeriod(period string, hours int64) error {
|
||||
switch period {
|
||||
case "", "hour", "week", "month":
|
||||
return nil
|
||||
case "nhour":
|
||||
if hours < 1 {
|
||||
return fmt.Errorf("period %q needs hours >= 1", period)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("period must be one of \"\", hour, week, month, nhour (got %q)", period)
|
||||
}
|
||||
|
||||
// ModelScope is one allowed model for a key, or one AUTO scheduling slot.
|
||||
// Its TokenQuota and ReqQuota cap THAT entry only, independently of every
|
||||
// other entry on the same key: a model that runs out of budget stops being
|
||||
// served while the key's other models keep working. TokenQuota 0 / ReqQuota 0
|
||||
// = unlimited. Period "" = never resets; "hour"/"week"/"month" are fixed
|
||||
// windows; "nhour" uses Hours.
|
||||
// ModelScope is one allowed model for a key, or one AUTO scheduling slot,
|
||||
// with an optional token quota and reset period. TokenQuota 0 = unlimited;
|
||||
// Period "" = never resets; "hour"/"week"/"month" are fixed windows; "nhour"
|
||||
// uses Hours as the window length in hours.
|
||||
type ModelScope struct {
|
||||
Model string `yaml:"model" json:"model"`
|
||||
Source string `yaml:"source,omitempty" json:"source,omitempty"` // optional: pin to one upstream source; "" = any source
|
||||
Tier int `yaml:"tier,omitempty" json:"tier,omitempty"`
|
||||
TokenQuota int64 `yaml:"token_quota" json:"token_quota"`
|
||||
ReqQuota int64 `yaml:"req_quota,omitempty" json:"req_quota,omitempty"`
|
||||
Period string `yaml:"period,omitempty" json:"period,omitempty"`
|
||||
Hours int64 `yaml:"hours,omitempty" json:"hours,omitempty"`
|
||||
}
|
||||
@ -565,7 +388,6 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error {
|
||||
Source string `json:"source"`
|
||||
Tier int `json:"tier"`
|
||||
TokenQuota int64 `json:"token_quota"`
|
||||
ReqQuota int64 `json:"req_quota"`
|
||||
Period string `json:"period"`
|
||||
Hours int64 `json:"hours"`
|
||||
}
|
||||
@ -576,7 +398,6 @@ func (m *ModelScope) UnmarshalJSON(b []byte) error {
|
||||
m.Source = o.Source
|
||||
m.Tier = o.Tier
|
||||
m.TokenQuota = o.TokenQuota
|
||||
m.ReqQuota = o.ReqQuota
|
||||
m.Period = o.Period
|
||||
m.Hours = o.Hours
|
||||
return nil
|
||||
|
||||
@ -52,6 +52,9 @@ sources:
|
||||
if cfg.Sources[1].MaxConcurrent == 0 {
|
||||
t.Fatal("default max_concurrent not applied")
|
||||
}
|
||||
if cfg.Sources[0].Models[0].Priority != 100 {
|
||||
t.Fatalf("priority = %d", cfg.Sources[0].Models[0].Priority)
|
||||
}
|
||||
if cfg.DefaultModel != "AUTO" {
|
||||
t.Fatalf("default model = %q", cfg.DefaultModel)
|
||||
}
|
||||
|
||||
@ -14,9 +14,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "deepseek",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "deepseek-v4-flash", Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Kind: "chat"},
|
||||
{ID: "deepseek-v4-flash-vision-exp", Kind: "chat"},
|
||||
{ID: "deepseek-v4-flash", Priority: 100, Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Priority: 90, Kind: "chat"},
|
||||
{ID: "deepseek-v4-flash-vision-exp", Priority: 60, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -25,10 +25,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "glm-5.3", Kind: "chat"},
|
||||
{ID: "glm-5.3-flash", Kind: "chat"},
|
||||
{ID: "glm-5.2", Kind: "chat"},
|
||||
{ID: "glm-5.1", Kind: "chat"},
|
||||
{ID: "glm-5.3", Priority: 100, Kind: "chat"},
|
||||
{ID: "glm-5.3-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "glm-5.2", Priority: 80, Kind: "chat"},
|
||||
{ID: "glm-5.1", Priority: 60, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -37,10 +37,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "kimi-k3", Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code", Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code-highspeed", Kind: "chat"},
|
||||
{ID: "kimi-k2.6", Kind: "chat"},
|
||||
{ID: "kimi-k3", Priority: 100, Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code", Priority: 90, Kind: "chat"},
|
||||
{ID: "kimi-k2.7-code-highspeed", Priority: 85, Kind: "chat"},
|
||||
{ID: "kimi-k2.6", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -49,11 +49,11 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Pro", Kind: "chat"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Flash", Kind: "chat"},
|
||||
{ID: "moonshotai/Kimi-K2.6", Kind: "chat"},
|
||||
{ID: "zai-org/GLM-5.1", Kind: "chat"},
|
||||
{ID: "Kwai-Kolors/Kolors", Kind: "image"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Pro", Priority: 100, Kind: "chat"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "moonshotai/Kimi-K2.6", Priority: 80, Kind: "chat"},
|
||||
{ID: "zai-org/GLM-5.1", Priority: 70, Kind: "chat"},
|
||||
{ID: "Kwai-Kolors/Kolors", Priority: 50, Kind: "image"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -62,9 +62,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "4.0Ultra", Kind: "chat"},
|
||||
{ID: "generalv3.5", Kind: "chat"},
|
||||
{ID: "max-32k", Kind: "chat"},
|
||||
{ID: "4.0Ultra", Priority: 100, Kind: "chat"},
|
||||
{ID: "generalv3.5", Priority: 80, Kind: "chat"},
|
||||
{ID: "max-32k", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -73,11 +73,11 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "qwen3.8-max", Kind: "chat"},
|
||||
{ID: "qwen3.8-flash", Kind: "chat"},
|
||||
{ID: "qwen3.7-plus", Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Kind: "chat"},
|
||||
{ID: "kimi-k3", Kind: "chat"},
|
||||
{ID: "qwen3.8-max", Priority: 100, Kind: "chat"},
|
||||
{ID: "qwen3.8-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "qwen3.7-plus", Priority: 85, Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
|
||||
{ID: "kimi-k3", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -86,9 +86,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "ernie-5.0-turbo", Kind: "chat"},
|
||||
{ID: "ernie-5.0", Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Kind: "chat"},
|
||||
{ID: "ernie-5.0-turbo", Priority: 100, Kind: "chat"},
|
||||
{ID: "ernie-5.0", Priority: 90, Kind: "chat"},
|
||||
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -97,9 +97,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "doubao-seed-2.0-pro", Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0", Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0-flash", Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0-pro", Priority: 100, Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0", Priority: 90, Kind: "chat"},
|
||||
{ID: "doubao-seed-2.0-flash", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -108,8 +108,8 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "MiniMax-M3", Kind: "chat"},
|
||||
{ID: "MiniMax-M2.5", Kind: "chat"},
|
||||
{ID: "MiniMax-M3", Priority: 100, Kind: "chat"},
|
||||
{ID: "MiniMax-M2.5", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 国际主流 ────────────────────────────────────────────────────────────
|
||||
@ -119,9 +119,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.6-sol", Kind: "chat"},
|
||||
{ID: "gpt-5.6-terra", Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Kind: "chat"},
|
||||
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
|
||||
{ID: "gpt-5.6-terra", Priority: 90, Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -130,10 +130,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "anthropic",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "claude-opus-5", Kind: "chat"},
|
||||
{ID: "claude-fable-5", Kind: "chat"},
|
||||
{ID: "claude-sonnet-5", Kind: "chat"},
|
||||
{ID: "claude-haiku-4-5", Kind: "chat"},
|
||||
{ID: "claude-opus-5", Priority: 100, Kind: "chat"},
|
||||
{ID: "claude-fable-5", Priority: 95, Kind: "chat"},
|
||||
{ID: "claude-sonnet-5", Priority: 85, Kind: "chat"},
|
||||
{ID: "claude-haiku-4-5", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -142,10 +142,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "gemini",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gemini-3.6-flash", Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash", Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash-lite", Kind: "chat"},
|
||||
{ID: "gemini-3.1-pro", Kind: "chat"},
|
||||
{ID: "gemini-3.6-flash", Priority: 100, Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "gemini-3.5-flash-lite", Priority: 80, Kind: "chat"},
|
||||
{ID: "gemini-3.1-pro", Priority: 85, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -154,9 +154,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "groq",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "openai/gpt-oss-120b", Kind: "chat"},
|
||||
{ID: "openai/gpt-oss-20b", Kind: "chat"},
|
||||
{ID: "groq/compound", Kind: "chat"},
|
||||
{ID: "openai/gpt-oss-120b", Priority: 100, Kind: "chat"},
|
||||
{ID: "openai/gpt-oss-20b", Priority: 80, Kind: "chat"},
|
||||
{ID: "groq/compound", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -165,9 +165,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "mistral",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "mistral-medium-latest", Kind: "chat"},
|
||||
{ID: "mistral-large-latest", Kind: "chat"},
|
||||
{ID: "codestral-latest", Kind: "chat"},
|
||||
{ID: "mistral-medium-latest", Priority: 100, Kind: "chat"},
|
||||
{ID: "mistral-large-latest", Priority: 90, Kind: "chat"},
|
||||
{ID: "codestral-latest", Priority: 85, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -180,10 +180,10 @@ var PresetTemplates = []SourceTemplate{
|
||||
"X-Title": "ModelRouter",
|
||||
},
|
||||
Models: []Model{
|
||||
{ID: "openrouter/auto", Kind: "chat"},
|
||||
{ID: "deepseek/deepseek-v4-flash", Kind: "chat"},
|
||||
{ID: "anthropic/claude-opus-5", Kind: "chat"},
|
||||
{ID: "openai/gpt-5.6-sol", Kind: "chat"},
|
||||
{ID: "openrouter/auto", Priority: 100, Kind: "chat"},
|
||||
{ID: "deepseek/deepseek-v4-flash", Priority: 90, Kind: "chat"},
|
||||
{ID: "anthropic/claude-opus-5", Priority: 85, Kind: "chat"},
|
||||
{ID: "openai/gpt-5.6-sol", Priority: 85, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -192,8 +192,8 @@ var PresetTemplates = []SourceTemplate{
|
||||
Adapter: "github",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.6-sol", Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Kind: "chat"},
|
||||
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 本地模型 ────────────────────────────────────────────────────────────
|
||||
@ -204,9 +204,9 @@ var PresetTemplates = []SourceTemplate{
|
||||
Endpoint: "/api/chat",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "qwen3", Kind: "chat"},
|
||||
{ID: "deepseek-r1", Kind: "chat"},
|
||||
{ID: "llama3.3", Kind: "chat"},
|
||||
{ID: "qwen3", Priority: 80, Kind: "chat"},
|
||||
{ID: "deepseek-r1", Priority: 80, Kind: "chat"},
|
||||
{ID: "llama3.3", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ShouldRetry 是纯数据判据:状态码列表 + 响应体子串。它决定"这次失败值不值得
|
||||
// 在网关内部等一会儿重试"。厂商知识绝不能进内核,所以判据必须能用一个表格测清楚。
|
||||
func TestRetryPolicyShouldRetry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
policy *RetryPolicy
|
||||
status int
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"nil policy", nil, 500, "anything", false},
|
||||
{"max_attempts=1 disables", &RetryPolicy{OnStatus: []int{400}, MaxAttempts: 1}, 400, "x", false},
|
||||
{"status matches, no body filter",
|
||||
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 429, "", true},
|
||||
{"status mismatch",
|
||||
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 400, "429", false},
|
||||
{"status + body match",
|
||||
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
|
||||
400, `{"error_code":"TM.00001041"}`, true},
|
||||
{"status matches but body does not",
|
||||
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
|
||||
400, `{"error_code":"TM.00001005"}`, false},
|
||||
{"empty substring never matches",
|
||||
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{""}, MaxAttempts: 3}, 400, "x", false},
|
||||
{"any of several body signals",
|
||||
&RetryPolicy{OnStatus: []int{400, 429}, OnBodyContains: []string{"TM.00001041", "TPM limit"}, MaxAttempts: 3},
|
||||
429, `{"message":"TPM limit exceeded"}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.policy.ShouldRetry(tc.status, tc.body); got != tc.want {
|
||||
t.Errorf("%s: ShouldRetry(%d, %q) = %v, want %v", tc.name, tc.status, tc.body, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryPolicyDelay(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
policy *RetryPolicy
|
||||
want time.Duration
|
||||
}{
|
||||
{"nil", nil, 10 * time.Second},
|
||||
{"explicit", &RetryPolicy{Delay: "250ms"}, 250 * time.Millisecond},
|
||||
{"garbage falls back", &RetryPolicy{Delay: "not-a-duration"}, 10 * time.Second},
|
||||
{"negative falls back", &RetryPolicy{Delay: "-5s"}, 10 * time.Second},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.policy.DelayDuration(); got != tc.want {
|
||||
t.Errorf("%s: DelayDuration() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -27,9 +27,8 @@ func NewGatewayKey() (string, error) {
|
||||
|
||||
// SecretBox encrypts and decrypts sensitive values (upstream API keys,
|
||||
// custom header values, gateway keys) for persist-time protection. The
|
||||
// master key comes from the MODELROUTER_MASTER_KEY environment variable
|
||||
// (hex, 64 chars; the pre-rename LLMS_PROXY_MASTER_KEY is still accepted) or
|
||||
// from a generated <runtime-dir>/master.key file.
|
||||
// master key comes from the LLMS_PROXY_MASTER_KEY environment variable
|
||||
// (hex, 64 chars) or from a generated <runtime-dir>/master.key file.
|
||||
type SecretBox struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
@ -38,18 +37,10 @@ type SecretBox struct {
|
||||
// file next to the runtime file. If neither exists, a fresh random key is
|
||||
// generated and written to master.key with mode 0600.
|
||||
func NewSecretBox(runtimeFile string) (*SecretBox, error) {
|
||||
// New name first, then the pre-rename name: an existing unit file that still
|
||||
// exports LLMS_PROXY_MASTER_KEY must keep starting after an upgrade.
|
||||
envKey := os.Getenv("MODELROUTER_MASTER_KEY")
|
||||
envName := "MODELROUTER_MASTER_KEY"
|
||||
if envKey == "" {
|
||||
envKey = os.Getenv("LLMS_PROXY_MASTER_KEY")
|
||||
envName = "LLMS_PROXY_MASTER_KEY"
|
||||
}
|
||||
if envKey != "" {
|
||||
key, err := hex.DecodeString(strings.TrimSpace(envKey))
|
||||
if v := os.Getenv("LLMS_PROXY_MASTER_KEY"); v != "" {
|
||||
key, err := hex.DecodeString(strings.TrimSpace(v))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, fmt.Errorf("%s: expected 64 hex chars", envName)
|
||||
return nil, fmt.Errorf("LLMS_PROXY_MASTER_KEY: expected 64 hex chars")
|
||||
}
|
||||
return newBox(key)
|
||||
}
|
||||
|
||||
@ -1,317 +0,0 @@
|
||||
package config
|
||||
|
||||
// Secret handling for config.yaml.
|
||||
//
|
||||
// config.yaml is 0644 world-readable by design (ops need to inspect it), so any
|
||||
// credential in it must not sit there in plaintext. Sources' api_key / headers
|
||||
// and gateway keys are therefore sealed at rest with the same SecretBox used by
|
||||
// the runtime store, and unsealed in memory at load time.
|
||||
//
|
||||
// The invariant that makes this safe: **in-memory values are always plaintext**,
|
||||
// and the enc:v1: prefix is what marks a file value as sealed. Read paths that
|
||||
// predate this (core.resolveSourceKey) already unseal, so only the write side and
|
||||
// the load-time normalize step are new.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sealSource seals one source's credentials in place (used by the YAML upsert
|
||||
// path, which is a package function and therefore has no Config to borrow a box
|
||||
// from).
|
||||
func sealSource(s *Source, box *SecretBox) error {
|
||||
if box == nil {
|
||||
return nil
|
||||
}
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
v, err := box.Encrypt(s.APIKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.APIKey = v
|
||||
}
|
||||
for k, v := range s.Headers {
|
||||
if v == "" || strings.HasPrefix(v, encPrefix) {
|
||||
continue
|
||||
}
|
||||
e, err := box.Encrypt(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Headers[k] = e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeSecrets unseals every credential in the freshly parsed config so the
|
||||
// rest of the program only ever sees plaintext. A value without the enc:v1:
|
||||
// prefix is left untouched, which keeps hand-written plaintext configs working
|
||||
// (and is what a pre-encryption config file looks like).
|
||||
//
|
||||
// A value that carries the prefix but fails to decrypt is a hard error, not
|
||||
// something to paper over: returning the ciphertext (MustDecrypt's behavior)
|
||||
// would let the next Save re-seal it and turn one bad value into permanent,
|
||||
// compounding corruption. Losing the master key must be loud.
|
||||
func (c *Config) normalizeSecrets(box *SecretBox) error {
|
||||
if box == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range c.Sources {
|
||||
s := &c.Sources[i]
|
||||
if strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
v, err := box.Decrypt(s.APIKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source %q api_key: %w", s.Name, err)
|
||||
}
|
||||
s.APIKey = v
|
||||
}
|
||||
for k, v := range s.Headers {
|
||||
if strings.HasPrefix(v, encPrefix) {
|
||||
d, err := box.Decrypt(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source %q header %q: %w", s.Name, k, err)
|
||||
}
|
||||
s.Headers[k] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range c.Keys {
|
||||
if strings.HasPrefix(c.Keys[i].Key, encPrefix) {
|
||||
v, err := box.Decrypt(c.Keys[i].Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway key %q: %w", c.Keys[i].Name, err)
|
||||
}
|
||||
c.Keys[i].Key = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sealInPlace replaces plaintext credentials with ciphertext for writing. It is
|
||||
// deliberately a separate step from Marshal: callers that need the plaintext
|
||||
// (auth comparisons, log output, returning a key to the operator who just
|
||||
// created it) must not be handed a sealed config by accident.
|
||||
func (c *Config) sealInPlace(box *SecretBox) error {
|
||||
if box == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range c.Sources {
|
||||
s := &c.Sources[i]
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
v, err := box.Encrypt(s.APIKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.APIKey = v
|
||||
}
|
||||
if len(s.Headers) > 0 {
|
||||
sealed := make(map[string]string, len(s.Headers))
|
||||
for k, v := range s.Headers {
|
||||
if v == "" || strings.HasPrefix(v, encPrefix) {
|
||||
sealed[k] = v
|
||||
continue
|
||||
}
|
||||
e, err := box.Encrypt(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sealed[k] = e
|
||||
}
|
||||
s.Headers = sealed
|
||||
}
|
||||
}
|
||||
for i := range c.Keys {
|
||||
k := &c.Keys[i]
|
||||
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
|
||||
v, err := box.Encrypt(k.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k.Key = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsealAfterWrite restores plaintext after a sealed marshal so the live process
|
||||
// keeps working on plaintext values (mirrors Store.persistLocked's dance).
|
||||
func (c *Config) unsealAfterWrite(box *SecretBox) {
|
||||
// Save just encrypted every value it can see, so a failure here is
|
||||
// impossible; ignore the error rather than panic in a write path.
|
||||
_ = c.normalizeSecrets(box)
|
||||
}
|
||||
|
||||
// hasPlaintextSecrets reports whether any credential in the config is still in
|
||||
// the clear. Used to decide whether a startup migration write is needed, and to
|
||||
// warn (without leaking values) when no master key is available.
|
||||
func (c *Config) hasPlaintextSecrets() bool {
|
||||
for _, s := range c.Sources {
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
return true
|
||||
}
|
||||
for _, v := range s.Headers {
|
||||
if v != "" && !strings.HasPrefix(v, encPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range c.Keys {
|
||||
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countPlaintextSecrets returns how many credentials are still in the clear, for
|
||||
// an operator-facing migration log line that must not print the values.
|
||||
func (c *Config) countPlaintextSecrets() int {
|
||||
n := 0
|
||||
for _, s := range c.Sources {
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
n++
|
||||
}
|
||||
for _, v := range s.Headers {
|
||||
if v != "" && !strings.HasPrefix(v, encPrefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range c.Keys {
|
||||
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// NormalizeSecretsForRun unseals the loaded config and then seals it back on
|
||||
// disk if anything was still in the clear. Order matters: Load() read the file
|
||||
// with ciphertext still in place, so the unseal has to happen before the
|
||||
// registry (and any Save the startup path performs) sees the values.
|
||||
func (c *Config) NormalizeSecretsForRun(box *SecretBox) error {
|
||||
hadPlaintext, err := c.UnsealSecrets(box)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SealIfNeeded(hadPlaintext)
|
||||
}
|
||||
|
||||
// UnsealSecrets decrypts every sealed credential in memory and reports whether
|
||||
// the config ON DISK still held plaintext (i.e. whether a sealing write is
|
||||
// needed). It never writes; a failed decrypt (wrong master key) is returned so
|
||||
// the process refuses to start instead of running with unusable credentials.
|
||||
//
|
||||
// The return value must be computed BEFORE unsealing and from the disk state,
|
||||
// not from memory: after a Save the in-memory values are always plaintext, so a
|
||||
// "is anything plaintext?" test run afterwards is unconditionally true and a
|
||||
// caller would rewrite the file on every start. That was the actual behaviour -
|
||||
// migratePlaintextSecrets() claimed to be idempotent in a comment but rewrote
|
||||
// config.yaml on every boot.
|
||||
//
|
||||
// Callers that consume credentials (the provider registry, key seeding) must
|
||||
// unseal FIRST. Seeding compares cfg.Keys[i].Key against the plaintext
|
||||
// gateway_keys entries; running it while keys are still ciphertext made the
|
||||
// dedupe never match, so every restart appended another copy of the same admin
|
||||
// key (production accumulated four).
|
||||
func (c *Config) UnsealSecrets(box *SecretBox) (bool, error) {
|
||||
if box == nil {
|
||||
return false, nil
|
||||
}
|
||||
c.AttachSecretBox(box)
|
||||
hadPlaintext := c.hasPlaintextSecrets()
|
||||
if err := c.normalizeSecrets(box); err != nil {
|
||||
return hadPlaintext, err
|
||||
}
|
||||
return hadPlaintext, nil
|
||||
}
|
||||
|
||||
// SealIfNeeded writes the config back once if hadPlaintext reported that the
|
||||
// file still held clear-text credentials. When it is false the file is left
|
||||
// untouched, which is what makes startup a no-op for an already-sealed config.
|
||||
func (c *Config) SealIfNeeded(hadPlaintext bool) error {
|
||||
if !hadPlaintext || c.box == nil || c.Path == "" {
|
||||
return nil
|
||||
}
|
||||
n := c.countPlaintextSecrets()
|
||||
if err := c.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[config] sealed %d plaintext credential(s) in %s", n, c.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachSecretBox wires the encryption box into the config so Save can seal
|
||||
// credentials. Kept as an explicit call (rather than a constructor argument) so
|
||||
// config.Load stays usable in contexts that have no filesystem secrets (tests,
|
||||
// `-check`).
|
||||
func (c *Config) AttachSecretBox(box *SecretBox) { c.box = box }
|
||||
|
||||
// SecretBox returns the wired encryption box, or nil when none is attached.
|
||||
func (c *Config) SecretBox() *SecretBox { return c.box }
|
||||
|
||||
// migratePlaintextSecrets seals any credential still in the clear and writes the
|
||||
// file once. Kept for callers that attach the box themselves; it decides from
|
||||
// the in-memory state, which is why UnsealSecrets + SealIfNeeded (which decide
|
||||
// from the on-disk state) are preferred on the startup path.
|
||||
func (c *Config) migratePlaintextSecrets() error {
|
||||
if c.box == nil || c.Path == "" {
|
||||
return nil
|
||||
}
|
||||
if !c.hasPlaintextSecrets() {
|
||||
return nil
|
||||
}
|
||||
n := c.countPlaintextSecrets()
|
||||
if err := c.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[config] sealed %d plaintext credential(s) in %s", n, c.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintSecrets writes the config's credentials to stdout in the clear and
|
||||
// returns an error only when the file cannot be read or the master key does not
|
||||
// match. It is the operator counterpart to sealing-at-rest: with keys stored as
|
||||
// ciphertext, "which key is this source using" must still be answerable.
|
||||
//
|
||||
// It deliberately takes a path rather than a *Config so the caller cannot
|
||||
// accidentally hand it a config that has already been unsealed in memory, and it
|
||||
// never writes anything.
|
||||
func PrintSecrets(path string) error {
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
box, err := NewSecretBox(cfg.RuntimeFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w (is master.key present and intact?)", err)
|
||||
}
|
||||
if err := cfg.normalizeSecrets(box); err != nil {
|
||||
return err
|
||||
}
|
||||
w := os.Stdout
|
||||
fmt.Fprintf(w, "# %s — %d source(s), %d gateway key(s)\n", path, len(cfg.Sources), len(cfg.Keys))
|
||||
for _, s := range cfg.Sources {
|
||||
fmt.Fprintf(w, "source %-16s api_key=%s\n", s.Name, orNone(s.APIKey))
|
||||
for k, v := range s.Headers {
|
||||
if v != "" {
|
||||
fmt.Fprintf(w, "source %-16s header[%s]=%s\n", s.Name, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range cfg.Keys {
|
||||
fmt.Fprintf(w, "key %-16s role=%-5s %s\n", k.Name, k.Role, k.Key)
|
||||
}
|
||||
fmt.Fprintf(w, "gateway_keys (legacy): %s\n", strings.Join(cfg.GatewayKeys, " "))
|
||||
return nil
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "(unset)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -1,323 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeMasterKey plants a deterministic master.key next to the given runtime
|
||||
// file so tests exercise the real box instead of the env-var shortcut.
|
||||
func writeMasterKey(t *testing.T, runtimeFile string) *SecretBox {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(runtimeFile), "master.key"),
|
||||
[]byte(strings.Repeat("ab", 32)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box, err := NewSecretBox(runtimeFile)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSecretBox: %v", err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
func TestSaveSealsCredentialsAndLoadUnseals(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
runtime := filepath.Join(dir, "runtime.json")
|
||||
box := writeMasterKey(t, runtime)
|
||||
|
||||
cfg := &Config{
|
||||
Path: path,
|
||||
Listen: "127.0.0.1:0",
|
||||
Sources: []Source{{
|
||||
Name: "up",
|
||||
BaseURL: "http://up/v1",
|
||||
APIKey: "sk-plaintext-secret",
|
||||
Adapter: "openai",
|
||||
Headers: map[string]string{"X-Extra": "header-secret"},
|
||||
Models: []Model{{ID: "m", Kind: "chat"}},
|
||||
}},
|
||||
Keys: []GWKey{{Key: "sk-gw-plain", Role: "admin", Name: "admin"}},
|
||||
}
|
||||
cfg.AttachSecretBox(box)
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(raw)
|
||||
if strings.Contains(text, "sk-plaintext-secret") {
|
||||
t.Error("source api_key written in plaintext")
|
||||
}
|
||||
if strings.Contains(text, "header-secret") {
|
||||
t.Error("source header written in plaintext")
|
||||
}
|
||||
if strings.Contains(text, "sk-gw-plain") {
|
||||
t.Error("gateway key written in plaintext")
|
||||
}
|
||||
if n := strings.Count(text, encPrefix); n != 3 {
|
||||
t.Errorf("expected 3 sealed values, found %d", n)
|
||||
}
|
||||
|
||||
// The live in-memory config must still hold plaintext after Save.
|
||||
if cfg.Sources[0].APIKey != "sk-plaintext-secret" {
|
||||
t.Errorf("in-memory api_key mutated by Save: %q", cfg.Sources[0].APIKey)
|
||||
}
|
||||
if cfg.Keys[0].Key != "sk-gw-plain" {
|
||||
t.Errorf("in-memory gateway key mutated by Save: %q", cfg.Keys[0].Key)
|
||||
}
|
||||
|
||||
// A fresh load must hand back plaintext again.
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
loaded.AttachSecretBox(box)
|
||||
loaded.normalizeSecrets(box)
|
||||
if loaded.Sources[0].APIKey != "sk-plaintext-secret" {
|
||||
t.Errorf("loaded api_key = %q, want plaintext", loaded.Sources[0].APIKey)
|
||||
}
|
||||
if loaded.Sources[0].Headers["X-Extra"] != "header-secret" {
|
||||
t.Errorf("loaded header = %q, want plaintext", loaded.Sources[0].Headers["X-Extra"])
|
||||
}
|
||||
if loaded.Keys[0].Key != "sk-gw-plain" {
|
||||
t.Errorf("loaded gateway key = %q, want plaintext", loaded.Keys[0].Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextConfigStillLoads(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
// A hand-written, pre-encryption config: no enc:v1: anywhere.
|
||||
body := `listen: 127.0.0.1:0
|
||||
sources:
|
||||
- name: legacy
|
||||
base_url: http://legacy/v1
|
||||
api_key: sk-written-by-hand
|
||||
adapter: openai
|
||||
models:
|
||||
- id: m
|
||||
kind: chat
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
box := writeMasterKey(t, filepath.Join(dir, "runtime.json"))
|
||||
cfg.AttachSecretBox(box)
|
||||
cfg.normalizeSecrets(box)
|
||||
if cfg.Sources[0].APIKey != "sk-written-by-hand" {
|
||||
t.Errorf("plaintext config value changed: %q", cfg.Sources[0].APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Sealing a config is a one-time migration: the FIRST run writes, every later
|
||||
// start must leave the file alone.
|
||||
//
|
||||
// This test previously called migratePlaintextSecrets() twice and compared
|
||||
// ModTime. That assertion was both weak and wrong:
|
||||
//
|
||||
// - migratePlaintextSecrets decides from IN-MEMORY state, and Save() ends by
|
||||
// unsealing memory so the running process keeps working. So every call sees
|
||||
// "plaintext present" and rewrites the file. Comparing ModTime hid this
|
||||
// because two writes inside one filesystem timestamp tick look identical —
|
||||
// the test flaked (~1 in 6) instead of failing.
|
||||
// - it also exercised a function production no longer calls on the startup
|
||||
// path, which now uses UnsealSecrets + SealIfNeeded (they decide from the
|
||||
// ON-DISK state).
|
||||
//
|
||||
// So the test targets that real path and compares file CONTENT, which cannot be
|
||||
// fooled by timestamp granularity.
|
||||
func TestSealingIsIdempotentAcrossStarts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
runtime := filepath.Join(dir, "runtime.json")
|
||||
box := writeMasterKey(t, runtime)
|
||||
|
||||
cfg := &Config{
|
||||
Path: path,
|
||||
Listen: "127.0.0.1:0",
|
||||
Sources: []Source{{Name: "up", BaseURL: "http://up/v1", APIKey: "sk-clear", Adapter: "openai", Models: []Model{{ID: "m"}}}},
|
||||
}
|
||||
cfg.AttachSecretBox(box)
|
||||
|
||||
hadPlaintext, err := cfg.UnsealSecrets(box)
|
||||
if err != nil {
|
||||
t.Fatalf("unseal: %v", err)
|
||||
}
|
||||
if !hadPlaintext {
|
||||
t.Fatal("a config written with a clear-text credential must report plaintext")
|
||||
}
|
||||
if err := cfg.SealIfNeeded(hadPlaintext); err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
first, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(first), encPrefix) {
|
||||
t.Fatal("sealing did not encrypt the value on disk")
|
||||
}
|
||||
// In-memory must be plaintext so the running process keeps working.
|
||||
if cfg.Sources[0].APIKey != "sk-clear" {
|
||||
t.Errorf("in-memory api_key = %q, want plaintext", cfg.Sources[0].APIKey)
|
||||
}
|
||||
|
||||
// Simulate the next start: load from disk, unseal, seal-if-needed. The file
|
||||
// was already sealed, so nothing may be written.
|
||||
cfg2, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg2.AttachSecretBox(box)
|
||||
hadPlaintext2, err := cfg2.UnsealSecrets(box)
|
||||
if err != nil {
|
||||
t.Fatalf("second unseal: %v", err)
|
||||
}
|
||||
if hadPlaintext2 {
|
||||
t.Error("a sealed config reported plaintext — SealIfNeeded would rewrite " +
|
||||
"the file on every start")
|
||||
}
|
||||
if err := cfg2.SealIfNeeded(hadPlaintext2); err != nil {
|
||||
t.Fatalf("second seal: %v", err)
|
||||
}
|
||||
second, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(first) != string(second) {
|
||||
t.Error("the second start rewrote an already-sealed config; startup must be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSealedValueDoesNotDoubleEncrypt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
box := writeMasterKey(t, filepath.Join(dir, "runtime.json"))
|
||||
cfg := &Config{Path: filepath.Join(dir, "config.yaml"), Sources: []Source{{Name: "a", BaseURL: "http://a"}}}
|
||||
cfg.AttachSecretBox(box)
|
||||
|
||||
once, err := box.Encrypt("sk-x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources[0].APIKey = once
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := box.Decrypt(cfg.Sources[0].APIKey)
|
||||
if err != nil {
|
||||
t.Fatalf("value became undecryptable: %v", err)
|
||||
}
|
||||
if got != "sk-x" {
|
||||
t.Errorf("round trip = %q, want sk-x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertSourceInYAMLSealsCredentials(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
box := writeMasterKey(t, filepath.Join(dir, "runtime.json"))
|
||||
|
||||
if err := os.WriteFile(path, []byte("listen: 127.0.0.1:0\nsources: []\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := Source{Name: "new", BaseURL: "http://new/v1", APIKey: "sk-fresh", Adapter: "openai", Models: []Model{{ID: "m"}}}
|
||||
if err := UpsertSourceInYAML(path, src.Name, src, box); err != nil {
|
||||
t.Fatalf("UpsertSourceInYAML: %v", err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(raw), "sk-fresh") {
|
||||
t.Error("newly added source stored its api_key in plaintext")
|
||||
}
|
||||
if !strings.Contains(string(raw), encPrefix) {
|
||||
t.Error("newly added source was not sealed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountPlaintextSecrets(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Sources: []Source{
|
||||
{Name: "a", APIKey: encPrefix + "abc", Headers: map[string]string{"H": encPrefix + "x"}},
|
||||
{Name: "b", APIKey: "sk-in-clear", Headers: map[string]string{"H2": "clear-too"}},
|
||||
},
|
||||
Keys: []GWKey{{Key: "sk-gw-clear"}},
|
||||
}
|
||||
if got := cfg.countPlaintextSecrets(); got != 3 {
|
||||
t.Errorf("countPlaintextSecrets = %d, want 3", got)
|
||||
}
|
||||
if !cfg.hasPlaintextSecrets() {
|
||||
t.Error("hasPlaintextSecrets = false, want true")
|
||||
}
|
||||
cfg.Sources[1].APIKey = encPrefix + "y"
|
||||
cfg.Sources[1].Headers["H2"] = encPrefix + "z"
|
||||
cfg.Keys[0].Key = encPrefix + "k"
|
||||
if cfg.hasPlaintextSecrets() {
|
||||
t.Error("hasPlaintextSecrets = true after sealing everything")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSecretsFailsLoudlyOnWrongMasterKey is the negative case that
|
||||
// guards the migration: with a wrong key, a sealed value must NOT be handed
|
||||
// back as ciphertext for a later re-seal (that compounds corruption and turns
|
||||
// one lost key file into permanently unusable config).
|
||||
func TestNormalizeSecretsFailsLoudlyOnWrongMasterKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
runtime := filepath.Join(dir, "runtime.json")
|
||||
good := writeMasterKey(t, runtime)
|
||||
|
||||
cfg := &Config{
|
||||
Path: path,
|
||||
Listen: "127.0.0.1:0",
|
||||
Sources: []Source{{Name: "up", BaseURL: "http://up/v1", APIKey: "sk-secret", Adapter: "openai", Models: []Model{{ID: "m"}}}},
|
||||
}
|
||||
cfg.AttachSecretBox(good)
|
||||
if err := cfg.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealedOnce, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A different master key must be rejected, loudly. NewSecretBox derives the
|
||||
// key path from the RUNTIME file's directory, so the wrong key has to live
|
||||
// in a different directory — otherwise it would read the good master.key.
|
||||
otherDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(otherDir, "master.key"),
|
||||
[]byte(strings.Repeat("cd", 32)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badBox, err := NewSecretBox(filepath.Join(otherDir, "runtime.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded.AttachSecretBox(badBox)
|
||||
if err := loaded.NormalizeSecretsForRun(badBox); err == nil {
|
||||
t.Fatal("expected an error with the wrong master key, got nil")
|
||||
}
|
||||
|
||||
// Crucially: the file must be untouched — no second seal on top.
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != string(sealedOnce) {
|
||||
t.Error("config file was rewritten despite the decrypt failure")
|
||||
}
|
||||
if n := strings.Count(string(after), encPrefix); n != strings.Count(string(sealedOnce), encPrefix) {
|
||||
t.Errorf("sealed count changed %d -> %d (double encryption)", strings.Count(string(sealedOnce), encPrefix), n)
|
||||
}
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
[
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
|
||||
"body": "",
|
||||
"ak": "AK1",
|
||||
"sk": "SK1",
|
||||
"st": "TOK1",
|
||||
"canonical": "GET\n/snap-manager/v1/statistics/plugin/\n\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:TOK1\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\nf40918a3339c9db2251933dcce410db05ff8a403de2767bc642140ea93cdc182",
|
||||
"signature": "5332d627c3b8f14973afe41905f23dce75eaf9a34134dff670cb19835beb0549",
|
||||
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE",
|
||||
"body": "",
|
||||
"ak": "AK2",
|
||||
"sk": "SK2",
|
||||
"st": "",
|
||||
"canonical": "GET\n/v1/ops/delivery/\nchannel=IDE\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n3c849b40bbad69b981eaf280e162878bf00687d08cb45df43ac792fc23add4f1",
|
||||
"signature": "a01248a4121fd943f0f3f07aac101cd649ddf34d162729bf06a6a12ae9916b1a",
|
||||
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim",
|
||||
"body": "{\"campaignId\":\"C1\",\"channel\":\"IDE\"}",
|
||||
"ak": "AK3",
|
||||
"sk": "SK3",
|
||||
"st": "T3",
|
||||
"canonical": "POST\n/v1/ops/claim/\n\ncontent-type:application/json\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19\nx-sdk-date:20261013T150405Z\nx-security-token:T3\n\ncontent-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token\n85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19",
|
||||
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n554c2607cff324a770b55e78e2a1c58afe466abf5e4eaa104a6e1fa96acfc733",
|
||||
"signature": "e5faa52ec51c36b38804aac45a8a19876314cec465ab8f905100e657e8ea68d5",
|
||||
"signed_headers": "content-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token"
|
||||
}
|
||||
]
|
||||
@ -7,19 +7,18 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/lua"
|
||||
"modelrouter/internal/provider"
|
||||
"modelrouter/internal/scheduler"
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
"llmsproxy/internal/provider"
|
||||
"llmsproxy/internal/scheduler"
|
||||
)
|
||||
|
||||
// Core owns the running configuration and adapters. mu guards every read and
|
||||
@ -30,18 +29,11 @@ type Core struct {
|
||||
mu sync.Mutex
|
||||
cfg *config.Config
|
||||
vm *lua.VM
|
||||
plugins *lua.Plugins
|
||||
store *config.Store
|
||||
scheduler *scheduler.Scheduler
|
||||
registry *provider.Registry
|
||||
autoChain atomic.Pointer[scheduler.Chain] // chat AUTO chain
|
||||
autoImageChain atomic.Pointer[scheduler.Chain] // image-generation AUTO chain
|
||||
|
||||
// keyAutoChains caches per-key AUTO chains, keyed by the gateway key.
|
||||
// Entries are rebuilt by rebuildKeyAutoChains whenever sources or key
|
||||
// config change; AutoChainFor reads them without taking c.mu so request
|
||||
// handling stays lock-free like the global chain lookup.
|
||||
keyAutoChains atomic.Pointer[map[string]*scheduler.Chain]
|
||||
}
|
||||
|
||||
// New builds the core from a config file plus runtime overlay.
|
||||
@ -60,80 +52,7 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
if err := c.vm.Start(); err != nil {
|
||||
return nil, fmt.Errorf("lua vm: %w", err)
|
||||
}
|
||||
// Plugins load AFTER the VM is up (a plugin is just another Lua state) but
|
||||
// BEFORE anything can serve traffic, so a plugin's request_end hook sees
|
||||
// the very first request. A plugin that fails to load is logged and skipped
|
||||
// rather than failing startup: plugins are optional extensions, and failing
|
||||
// to boot the gateway because a third-party .lua has a typo would be the
|
||||
// wrong trade.
|
||||
c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir)
|
||||
// R4-c: hand plugins the kernel action surface. Injected as an interface
|
||||
// because lua must not import core (core depends on lua, not the reverse).
|
||||
// Wired BEFORE LoadDir so a plugin that calls an action during its own
|
||||
// __init/first hook already finds a working kernel rather than a nil one.
|
||||
c.plugins.SetKernelAPI(c)
|
||||
// Plugin outbound HTTP is scoped to the hosts of configured sources. Set
|
||||
// before LoadDir so a plugin that requests on its first hook already sees
|
||||
// the real allowlist rather than the deny-all placeholder.
|
||||
c.plugins.SetOutboundHosts(sourceHosts(cfg))
|
||||
// Let the plugin layer validate access specs against the keys that exist.
|
||||
// A stored allowlist naming a key that does not exist refuses every caller,
|
||||
// which looks exactly like a broken endpoint.
|
||||
lua.SetKeyNameLookup(func() []string {
|
||||
// ListKeys is the structured view (bare `gateway_keys:` entries are
|
||||
// expanded into records with names); cfg.GatewayKeys is still []string.
|
||||
keys := c.ListKeys()
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k.Name != "" {
|
||||
out = append(out, k.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
// Operator overrides win over the plugin's own declared audience.
|
||||
for name, aud := range cfg.PluginVisibility {
|
||||
c.plugins.SetPluginVisibility(name, aud)
|
||||
}
|
||||
if err := c.plugins.LoadDir(); err != nil {
|
||||
log.Printf("[core] plugin dir: %v", err)
|
||||
}
|
||||
for _, p := range c.plugins.List() {
|
||||
if msg, bad := p["error"]; bad {
|
||||
log.Printf("[core] plugin %v failed to load: %v", p["name"], msg)
|
||||
continue
|
||||
}
|
||||
log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)",
|
||||
p["name"], p["version"], p["hooks"], p["ui"])
|
||||
}
|
||||
c.store = config.NewStore(cfg.RuntimeFile)
|
||||
// Share one box between the runtime store and config.yaml so a single
|
||||
// master.key seals both files. config.Load left the config holding
|
||||
// ciphertext (if it was sealed); unseal it NOW, before anything reads a
|
||||
// credential.
|
||||
//
|
||||
// ORDER IS LOAD-BEARING. These steps each consume secrets and must run
|
||||
// after the unseal:
|
||||
// - seedKeys compares cfg.Keys[i].Key against the plaintext
|
||||
// gateway_keys entries; with ciphertext keys the comparison never
|
||||
// matched and every restart appended another duplicate admin key
|
||||
// (production had four copies of the same admin key).
|
||||
// - rebuildRegistry hands cfg.Sources[i].APIKey to the providers; with
|
||||
// ciphertext it built every upstream client with "enc:v1:..." as its
|
||||
// bearer token.
|
||||
// Previously the unseal happened at the END of this function, and things
|
||||
// only appeared to work because seedKeys' Save() unsealed memory as a side
|
||||
// effect. Removing the redundant saves exposed the real ordering bug.
|
||||
cfg.AttachSecretBox(c.store.SecretBox())
|
||||
// Share that SAME box with the plugin credential vault: one master.key seals
|
||||
// config.yaml, the runtime store, AND every .secrets/*.json. Set here (not at
|
||||
// NewPlugins time) because the store — and thus the box — only exists now;
|
||||
// plugins write secrets only during login flows, well after this point.
|
||||
c.plugins.SetSecretBox(c.store.SecretBox())
|
||||
hadPlaintextSecrets, err := cfg.UnsealSecrets(c.store.SecretBox())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unseal secrets: %w", err)
|
||||
}
|
||||
if err := c.store.Load(); err != nil {
|
||||
return nil, fmt.Errorf("runtime store: %w", err)
|
||||
}
|
||||
@ -144,6 +63,9 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
if err := c.seedKeys(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.seedAuto(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.rebuildRegistry(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -152,12 +74,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
if err := c.seedPresetTemplates(); err != nil {
|
||||
return nil, fmt.Errorf("seed preset templates: %w", err)
|
||||
}
|
||||
// Seal any credential still in the clear in config.yaml. Startup writes
|
||||
// nothing when the file was already sealed (hadPlaintextSecrets is decided
|
||||
// from the on-disk state, before the unseal above).
|
||||
if err := cfg.SealIfNeeded(hadPlaintextSecrets); err != nil {
|
||||
return nil, fmt.Errorf("normalize secrets: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@ -186,6 +102,39 @@ func (c *Core) migrateFromRuntime() {
|
||||
}
|
||||
}
|
||||
|
||||
// seedAuto uses the existing auto rules from config.yaml, or creates an
|
||||
// initial chain from legacy per-source model priority (first run only).
|
||||
func (c *Core) seedAuto() error {
|
||||
if len(c.cfg.Auto) > 0 {
|
||||
return nil
|
||||
}
|
||||
type item struct {
|
||||
model string
|
||||
prio int
|
||||
}
|
||||
var flat []item
|
||||
for _, s := range c.mergedSources() {
|
||||
for _, m := range s.Models {
|
||||
if m.Kind == "image" {
|
||||
continue
|
||||
}
|
||||
flat = append(flat, item{m.ID, m.Priority})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(flat, func(i, j int) bool {
|
||||
if flat[i].prio != flat[j].prio {
|
||||
return flat[i].prio > flat[j].prio
|
||||
}
|
||||
return flat[i].model < flat[j].model
|
||||
})
|
||||
entries := make([]config.ModelScope, 0, len(flat))
|
||||
for _, it := range flat {
|
||||
entries = append(entries, config.ModelScope{Model: it.model})
|
||||
}
|
||||
c.cfg.Auto = entries
|
||||
return c.cfg.Save()
|
||||
}
|
||||
|
||||
// seedKeys ensures config.yaml has admin keys for every gateway_keys entry
|
||||
// from the YAML config (once). After first save they become GWKey records
|
||||
// in c.cfg.Keys and gateway_keys is no longer read for auth.
|
||||
@ -243,11 +192,6 @@ func buildRetries(cfg *config.Config) int {
|
||||
// VM exposes the Lua adapter runtime.
|
||||
func (c *Core) VM() *lua.VM { return c.vm }
|
||||
|
||||
// Plugins exposes the loaded plugin set. Never nil once New* has returned, so
|
||||
// the gateway can fire stages unconditionally; an unconfigured plugin
|
||||
// directory yields an empty registry whose Fire is a no-op.
|
||||
func (c *Core) Plugins() *lua.Plugins { return c.plugins }
|
||||
|
||||
func (c *Core) Scheduler() *scheduler.Scheduler { return c.scheduler }
|
||||
|
||||
func (c *Core) Registry() *provider.Registry { return c.registry }
|
||||
@ -255,68 +199,6 @@ func (c *Core) Registry() *provider.Registry { return c.registry }
|
||||
// AutoChain returns the current AUTO scheduling chain.
|
||||
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
|
||||
|
||||
// AutoChainFor returns the AUTO chain to use for a request authenticated with
|
||||
// key. A key that declares its own chain gets that chain; every other key
|
||||
// inherits the global chain, so keys created before per-key chains existed
|
||||
// keep their current behaviour with no configuration change.
|
||||
//
|
||||
// It returns (chain, ok). ok is false only when the key has its own chain but
|
||||
// it compiled to nothing usable (e.g. every slot names a model that no longer
|
||||
// exists) — the caller must surface that rather than silently downgrading the
|
||||
// user to the global chain, which would be a confusing, invisible switch.
|
||||
func (c *Core) AutoChainFor(key string) (*scheduler.Chain, bool) {
|
||||
if m := c.keyAutoChains.Load(); m != nil {
|
||||
if ch, ok := (*m)[key]; ok {
|
||||
return ch, true
|
||||
}
|
||||
}
|
||||
return c.autoChain.Load(), true
|
||||
}
|
||||
|
||||
// rebuildKeyAutoChains recompiles every per-key AUTO chain from the current
|
||||
// config and provider registry, then swaps the cache in one shot so requests
|
||||
// never observe a half-built map. Keys without their own chain are absent
|
||||
// from the map and fall back to the global chain.
|
||||
func (c *Core) rebuildKeyAutoChains() {
|
||||
m := make(map[string]*scheduler.Chain)
|
||||
for _, k := range c.cfg.Keys {
|
||||
if !k.HasOwnAuto() {
|
||||
continue
|
||||
}
|
||||
m[k.Key] = c.chainForRules(k.Auto, false)
|
||||
}
|
||||
c.keyAutoChains.Store(&m)
|
||||
}
|
||||
|
||||
// SaveKeyAuto persists one key's own AUTO chain and recompiles the per-key
|
||||
// chain cache. Passing an empty list clears the override so the key inherits
|
||||
// the global chain again.
|
||||
func (c *Core) SaveKeyAuto(key string, entries []config.ModelScope) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
idx := -1
|
||||
for i, k := range c.cfg.Keys {
|
||||
if k.Key == key {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("key not found")
|
||||
}
|
||||
if err := ValidateScopeQuotas(entries); err != nil {
|
||||
return err
|
||||
}
|
||||
c.cfg.Keys[idx].Auto = cleanScopes(entries)
|
||||
if err := c.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Rebuild the whole cache so a cleared override stops shadowing the
|
||||
// global chain for this key.
|
||||
c.rebuildKeyAutoChains()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoImageChain returns the persisted image-generation AUTO chain snapshot.
|
||||
func (c *Core) AutoImageChain() *scheduler.Chain { return c.autoImageChain.Load() }
|
||||
|
||||
@ -356,15 +238,10 @@ func (c *Core) FindKey(key string) (config.GWKey, bool) {
|
||||
}
|
||||
|
||||
// CreateKey builds a new random gateway key and persists it to config.yaml.
|
||||
// Quotas live on the model scope entries, so a new key's budget is whatever
|
||||
// its scopes carry.
|
||||
func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
models = cleanScopes(models)
|
||||
if err := ValidateScopeQuotas(models); err != nil {
|
||||
return config.GWKey{}, err
|
||||
}
|
||||
key := make([]byte, 16)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return config.GWKey{}, err
|
||||
@ -377,7 +254,9 @@ func (c *Core) CreateKey(name, role string, models []config.ModelScope, note str
|
||||
Note: note,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
rec.Role = config.NormalizeRole(rec.Role)
|
||||
if rec.Role == "" {
|
||||
rec.Role = "user"
|
||||
}
|
||||
c.cfg.Keys = append(c.cfg.Keys, rec)
|
||||
if err := c.saveConfig(); err != nil {
|
||||
return config.GWKey{}, err
|
||||
@ -385,16 +264,10 @@ func (c *Core) CreateKey(name, role string, models []config.ModelScope, note str
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// UpdateKey mutates a key's name/role/model scope and persists it. The scope
|
||||
// entries carry their own quotas, so replacing the scope replaces the budgets.
|
||||
// UpdateKey mutates a key's name/role/model scope and persists it.
|
||||
func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, note string) (config.GWKey, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if models != nil {
|
||||
if err := ValidateScopeQuotas(models); err != nil {
|
||||
return config.GWKey{}, err
|
||||
}
|
||||
}
|
||||
for i, k := range c.cfg.Keys {
|
||||
if k.Key == key {
|
||||
if name != "" {
|
||||
@ -566,11 +439,6 @@ func (c *Core) mergedSources() []config.Source {
|
||||
}
|
||||
|
||||
// resolveSourceKey applies api_key_env and decrypts enc:v1: ciphertext.
|
||||
// Config values are already unsealed at startup (Config.NormalizeSecretsForRun),
|
||||
// so the decrypt branch is the belt-and-braces path for a source that arrived
|
||||
// already sealed through another route. A failure there leaves the ciphertext in
|
||||
// place, which makes the upstream reject the key loudly rather than sending an
|
||||
// empty Authorization header that might look like a config-less source.
|
||||
func (c *Core) resolveSourceKey(s config.Source) config.Source {
|
||||
if s.APIKeyEnv != "" {
|
||||
if v := os.Getenv(s.APIKeyEnv); v != "" {
|
||||
@ -579,11 +447,7 @@ func (c *Core) resolveSourceKey(s config.Source) config.Source {
|
||||
return s
|
||||
}
|
||||
if box := c.store.SecretBox(); box != nil && strings.HasPrefix(s.APIKey, "enc:v1:") {
|
||||
if v, err := box.Decrypt(s.APIKey); err == nil {
|
||||
s.APIKey = v
|
||||
} else {
|
||||
log.Printf("[core] source %s: api_key decrypt failed: %v", s.Name, err)
|
||||
}
|
||||
s.APIKey = box.MustDecrypt(s.APIKey)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -606,98 +470,26 @@ func (c *Core) rebuildRegistry() error {
|
||||
}
|
||||
c.buildAutoChain()
|
||||
c.buildAutoImageChain()
|
||||
c.rebuildKeyAutoChains()
|
||||
// A source edit can add or remove an upstream host, which changes what a
|
||||
// plugin is allowed to reach. Keeping this beside the chain rebuilds means
|
||||
// one call site owns "config changed, refresh derived state".
|
||||
c.plugins.SetOutboundHosts(sourceHosts(c.cfg))
|
||||
return nil
|
||||
}
|
||||
|
||||
// sourceHosts extracts the host of every configured source's base_url — the
|
||||
// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than
|
||||
// failing startup: a malformed base_url is already rejected when the provider is
|
||||
// built, and it must not take plugins down with it.
|
||||
//
|
||||
// A source may also imply a SECOND host. OpenDesign splits inference
|
||||
// (amr-link.open-design.ai) from billing (amr-api.open-design.ai), and the
|
||||
// quota plugin derives the billing host from the inference one — so the host it
|
||||
// asks for is not the host the operator configured, and the request would be
|
||||
// refused with a message that reads like a plugin defect. Adding the derived
|
||||
// host here is what makes the probe reachable.
|
||||
//
|
||||
// The grant stays scoped: only a source that IS on amr-link pulls in amr-api, so
|
||||
// a deployment with no OpenDesign source does not silently gain the entry.
|
||||
func sourceHosts(cfg *config.Config) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(cfg.Sources))
|
||||
seen := make(map[string]bool, len(cfg.Sources))
|
||||
add := func(h string) {
|
||||
if h == "" || seen[h] {
|
||||
return
|
||||
}
|
||||
seen[h] = true
|
||||
out = append(out, h)
|
||||
}
|
||||
for _, s := range cfg.Sources {
|
||||
if s.BaseURL == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(s.BaseURL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
continue
|
||||
}
|
||||
add(u.Hostname())
|
||||
add(deriveBillingHost(u.Hostname()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deriveBillingHost returns the billing host a configured source implies, or
|
||||
// the host unchanged when it implies none.
|
||||
//
|
||||
// Kept in Go as well as in the Lua adapter because the two must agree: Go builds
|
||||
// the allowlist and Lua builds the request URL, and a disagreement shows up as a
|
||||
// refused request rather than as an error naming the mismatch. The rule is the
|
||||
// full hostname, not a prefix — `amr-link.` as a prefix would also rewrite
|
||||
// amr-link.example.com into an OpenDesign address.
|
||||
func deriveBillingHost(host string) string {
|
||||
if host == "amr-link.open-design.ai" {
|
||||
return "amr-api.open-design.ai"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules
|
||||
// against the current providers. Slot model ids are normalized to the exact
|
||||
// configured spelling (case-insensitive match), otherwise ModelFor would fall
|
||||
// back to the source's best chat model and cooldown/quota bookkeeping would
|
||||
// key on a name that never matches.
|
||||
func (c *Core) buildAutoChain() {
|
||||
c.autoChain.Store(c.chainForRules(c.cfg.Auto, false))
|
||||
}
|
||||
|
||||
// chainForRules compiles a chat AUTO chain from a rule list. kindFilter is
|
||||
// false for the chat chain (image models are skipped) and true for the image
|
||||
// chain (only image models participate). Per-key chains always compile as
|
||||
// chat chains: a user's key configures the models *they* talk to.
|
||||
func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *scheduler.Chain {
|
||||
prov := func(model, source string) scheduler.Provider {
|
||||
p := c.registry.ProviderForSlot(model, source)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
m := p.ModelByID(model)
|
||||
if imageOnly && (m == nil || m.Kind != "image") {
|
||||
return nil
|
||||
}
|
||||
if !imageOnly && m != nil && m.Kind == "image" {
|
||||
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
rules := c.cfg.Auto
|
||||
sr := make([]scheduler.Rule, 0, len(rules))
|
||||
for _, e := range rules {
|
||||
model, source := e.Model, e.Source
|
||||
@ -706,11 +498,7 @@ func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *schedul
|
||||
model = exact
|
||||
}
|
||||
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
|
||||
if !imageOnly {
|
||||
continue // image-kind models never join the chat AUTO chain
|
||||
}
|
||||
} else if imageOnly {
|
||||
continue
|
||||
continue // image-kind models never join the chat AUTO chain
|
||||
}
|
||||
if source == "" {
|
||||
source = p.Name()
|
||||
@ -725,7 +513,7 @@ func (c *Core) chainForRules(rules []config.ModelScope, imageOnly bool) *schedul
|
||||
Hours: e.Hours,
|
||||
})
|
||||
}
|
||||
return scheduler.BuildChain(sr, prov)
|
||||
c.autoChain.Store(scheduler.BuildChain(sr, prov))
|
||||
}
|
||||
|
||||
// buildAutoImageChain rebuilds the image-generation AUTO chain snapshot.
|
||||
@ -835,35 +623,13 @@ func (c *Core) Reload() error {
|
||||
// ---- adapter management (web UI) ----
|
||||
|
||||
func (c *Core) ListAdapters() []lua.APIAdapter {
|
||||
c.syncHiddenRefs()
|
||||
return c.vm.ListAdapters()
|
||||
}
|
||||
|
||||
// syncHiddenRefs 把"当前被源引用的适配器"同步给 VM。被引用的 hidden=true 适配器
|
||||
// 必须保持可见(源编辑器要回显当前值);没被引用的隐藏适配器从列表消失。
|
||||
// 每次 ListAdapters 前调用:源集合是动态的(运行时可增删),推模型容易漏。
|
||||
func (c *Core) syncHiddenRefs() {
|
||||
refs := []string{}
|
||||
for _, s := range c.mergedSources() {
|
||||
if s.Adapter != "" {
|
||||
refs = append(refs, s.Adapter)
|
||||
}
|
||||
}
|
||||
c.vm.SetHiddenRefs(refs)
|
||||
}
|
||||
|
||||
func (c *Core) UploadAdapter(name, code string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("adapter name required")
|
||||
}
|
||||
// Validate name SAFETY before touching the filesystem (R6). A path-traversal
|
||||
// name ("../openai") must be rejected BEFORE WriteFile, or it would already
|
||||
// have escaped the adapter dir by the time LoadAdapter re-checks. Built-in
|
||||
// names are allowed here: an admin uploading openai.lua is the documented
|
||||
// override, same as placing the file in adapter_dir by hand.
|
||||
if err := lua.ValidateAdapterName(name, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(c.cfg.AdapterDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -875,13 +641,6 @@ func (c *Core) UploadAdapter(name, code string) error {
|
||||
}
|
||||
|
||||
func (c *Core) RemoveAdapter(name string) error {
|
||||
// Validate the name so a traversal name cannot os.Remove a file outside the
|
||||
// adapter dir. A reserved (built-in) name is a legitimate delete target in
|
||||
// the running VM, so reserved-name rejection is NOT applied here — only the
|
||||
// path-safety character set is.
|
||||
if err := lua.ValidateAdapterName(name, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(c.cfg.AdapterDir, name+".lua")
|
||||
_ = os.Remove(path)
|
||||
c.vm.RemoveAdapter(name)
|
||||
@ -896,7 +655,7 @@ func (c *Core) AddSource(src config.Source) error {
|
||||
if err := normalizeSource(&src); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := config.UpsertSourceInYAML(c.cfg.Path, src.Name, src, c.cfg.SecretBox()); err != nil {
|
||||
if err := config.UpsertSourceInYAML(c.cfg.Path, src.Name, src); err != nil {
|
||||
return err
|
||||
}
|
||||
// Update in-memory Sources so mergedSources() finds the entry.
|
||||
@ -993,31 +752,7 @@ func normalizeSource(s *config.Source) error {
|
||||
|
||||
// Close releases resources.
|
||||
func (c *Core) Close() {
|
||||
// Plugin state must be flushed BEFORE the VM stops. The saver's final write
|
||||
// reads each plugin's Lua tables; once vm.Stop() has closed those states the
|
||||
// read finds nothing and the last interval of accumulation is lost — which
|
||||
// is the exact failure this persistence was added to prevent.
|
||||
if c.plugins != nil {
|
||||
c.plugins.Close()
|
||||
}
|
||||
if c.vm != nil {
|
||||
c.vm.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
|
||||
// A typo in a period must be rejected at write time rather than silently
|
||||
// becoming a never-resetting budget — the opposite of what was typed.
|
||||
func ValidateScopeQuotas(entries []config.ModelScope) error {
|
||||
for _, e := range entries {
|
||||
if err := (config.KeyQuota{
|
||||
TokenQuota: e.TokenQuota,
|
||||
ReqQuota: e.ReqQuota,
|
||||
Period: e.Period,
|
||||
Hours: e.Hours,
|
||||
}).Validate(); err != nil {
|
||||
return fmt.Errorf("model %q: %w", e.Model, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// newTestConfig returns an in-memory config backed by temp files so
|
||||
@ -22,7 +22,7 @@ func newTestConfig(t *testing.T) *config.Config {
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-gw-test"},
|
||||
Sources: []config.Source{
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}}},
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,401 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 内核动作实现(R4-c)
|
||||
//
|
||||
// 把 Core 的公开动作暴露给插件 Lua,走 lua.KernelAPI 接口注入(依赖方向是
|
||||
// core → lua,所以接口定义在 lua 包、实现放在这里)。
|
||||
//
|
||||
// # 为什么是"动作面"而不是"给插件一个 Core 指针"
|
||||
//
|
||||
// 直接暴露 *Core 会让插件能碰任何未导出的细节,而且每一次内核重构都会变成
|
||||
// 对插件作者的破坏性变更。动作面是一份**显式的契约**:每个动作有名字、危险级、
|
||||
// 超时预算,加动作是一次有意的行为,而不是顺手多导出一个方法。
|
||||
//
|
||||
// # 凭据边界
|
||||
//
|
||||
// 这里**不实现** list_keys / find_key。Core.ListKeys() 返回的是**明文**网关
|
||||
// 密钥,把它交给第三方 Lua 等于把部署的整个凭据面交出去。key 相关的读走掩码
|
||||
// 形态(create_key 的返回是唯一一次明文,且是"刚创建的那一个")。
|
||||
var _ lua.KernelAPI = (*Core)(nil)
|
||||
|
||||
// Actions implements lua.KernelAPI.
|
||||
//
|
||||
// The catalog lives in the lua package because permissions must be checkable even
|
||||
// when no Core is wired. This method exists so the interface is satisfied by the
|
||||
// real kernel rather than by a separate adapter type.
|
||||
func (c *Core) Actions() []lua.KernelAction { return lua.KernelActions() }
|
||||
|
||||
// Invoke implements lua.KernelAPI. It dispatches one action.
|
||||
//
|
||||
// Every action is expected to honour ctx. The caller (lua.callKernel) gives up on
|
||||
// a deadline and returns an error to the plugin, but a kernel action that keeps
|
||||
// running afterwards would be an invisible leak, so the long ones (probe_all)
|
||||
// pass ctx down into the network layer.
|
||||
func (c *Core) Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) {
|
||||
switch name {
|
||||
// ---- 只读 ----
|
||||
case "status":
|
||||
return c.actionStatus(), nil
|
||||
case "list_sources":
|
||||
return c.actionListSources(), nil
|
||||
case "list_models":
|
||||
return c.actionListModels(), nil
|
||||
case "list_adapters":
|
||||
return c.actionListAdapters(), nil
|
||||
case "list_auto_rules":
|
||||
return c.actionListAutoRules(), nil
|
||||
case "adapter_owner":
|
||||
return c.actionAdapterOwner(args)
|
||||
|
||||
// ---- 写 ----
|
||||
case "reload":
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true}, c.Reload()
|
||||
case "reset_health":
|
||||
// ResetHealth already no-ops on a nil registry (it ranges over
|
||||
// Providers()), so this needs no guard beyond the nil check inside.
|
||||
c.ResetHealth()
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
case "probe_all":
|
||||
r := c.Registry()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("the registry is not built yet")
|
||||
}
|
||||
r.ProbeAll(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("probe_all was cut short: %w", err)
|
||||
}
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
case "save_auto_rules":
|
||||
return c.actionSaveAutoRules(args)
|
||||
case "save_key_auto":
|
||||
return c.actionSaveKeyAuto(args)
|
||||
case "add_source":
|
||||
return c.actionAddSource(args)
|
||||
case "create_key":
|
||||
return c.actionCreateKey(args)
|
||||
|
||||
// ---- 破坏性 ----
|
||||
case "remove_source":
|
||||
return c.actionRemoveSource(args)
|
||||
case "delete_key":
|
||||
return c.actionDeleteKey(args)
|
||||
case "update_key":
|
||||
return c.actionUpdateKey(args)
|
||||
case "remove_adapter":
|
||||
return c.actionRemoveAdapter(args)
|
||||
case "set_plugin_enabled":
|
||||
return c.actionSetPluginEnabled(args)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown kernel action %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 只读实现 ----
|
||||
|
||||
func (c *Core) actionStatus() interface{} {
|
||||
// A plugin can call this DURING LoadDir (its own __init or first hook), which
|
||||
// runs before rebuildRegistry builds the registry. Returning an empty status
|
||||
// rather than panicking keeps a load-time call from taking the process down;
|
||||
// the plugin sees an empty list and the next call gives real data.
|
||||
out := map[string]interface{}{
|
||||
"sources": []interface{}{},
|
||||
"auto_slots": []interface{}{},
|
||||
"default": c.cfg.DefaultModel,
|
||||
"ready": false,
|
||||
}
|
||||
if r := c.Registry(); r != nil {
|
||||
out["sources"] = r.Status()
|
||||
out["ready"] = true
|
||||
}
|
||||
if slots := c.AutoSlotStates(); slots != nil {
|
||||
out["auto_slots"] = slots
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actionListSources returns the sources with api_key MASKED.
|
||||
//
|
||||
// Masking is not optional: a plugin has no legitimate need for another source's
|
||||
// key, and a plugin that wrote the source itself already has it in its own
|
||||
// credential vault.
|
||||
func (c *Core) actionListSources() interface{} {
|
||||
srcs := c.Sources()
|
||||
out := make([]map[string]interface{}, 0, len(srcs))
|
||||
for _, s := range srcs {
|
||||
models := make([]string, 0, len(s.Models))
|
||||
for _, m := range s.Models {
|
||||
models = append(models, m.ID)
|
||||
}
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"base_url": s.BaseURL,
|
||||
"adapter": s.Adapter,
|
||||
"models": models,
|
||||
"has_key": s.APIKey != "" || s.APIKeyEnv != "",
|
||||
"key_hint": maskSecretHint(s.APIKey),
|
||||
"max_concurrent": s.MaxConcurrent,
|
||||
// rpm must be exposed too: it carries the per-model minimum gap, and
|
||||
// max_concurrent alone cannot distinguish "serial WITH a gap" from
|
||||
// "serial with no gap" — the latter still collides with the
|
||||
// upstream's per-model concurrency window.
|
||||
"rpm": s.RPM,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// maskSecretHint shows only a short suffix so an operator (and a plugin) can tell
|
||||
// two keys apart without either getting the secret.
|
||||
func maskSecretHint(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if len(k) <= 4 {
|
||||
return "***"
|
||||
}
|
||||
return "***" + k[len(k)-4:]
|
||||
}
|
||||
|
||||
func (c *Core) actionListModels() interface{} {
|
||||
if r := c.Registry(); r != nil {
|
||||
return r.ModelList()
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (c *Core) actionListAdapters() interface{} {
|
||||
return c.ListAdapters()
|
||||
}
|
||||
|
||||
func (c *Core) actionListAutoRules() interface{} {
|
||||
return map[string]interface{}{
|
||||
"auto": c.AutoRules(),
|
||||
"auto_image": c.AutoImageRules(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Core) actionAdapterOwner(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("adapter_owner requires a name")
|
||||
}
|
||||
owner := ""
|
||||
if ps := c.Plugins(); ps != nil {
|
||||
owner = ps.AdapterOwnerOf(name)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": name,
|
||||
"owner": owner, // "" means operator-installed
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- 写实现 ----
|
||||
|
||||
func (c *Core) actionSaveAutoRules(args map[string]interface{}) (interface{}, error) {
|
||||
entries, err := decodeScopes(args["entries"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.SaveAutoRules(entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionSaveKeyAuto(args map[string]interface{}) (interface{}, error) {
|
||||
keyName, _ := args["key_name"].(string)
|
||||
if keyName == "" {
|
||||
return nil, fmt.Errorf("save_key_auto requires key_name")
|
||||
}
|
||||
entries, err := decodeScopes(args["entries"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.SaveKeyAuto(keyName, entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
|
||||
}
|
||||
|
||||
// actionAddSource is the capability that makes "plugin-driven upstream" real: a
|
||||
// plugin that has just obtained credentials can wire its endpoint in as a source
|
||||
// without the operator editing config.yaml.
|
||||
func (c *Core) actionAddSource(args map[string]interface{}) (interface{}, error) {
|
||||
raw, ok := args["source"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("add_source requires a source table")
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source is not encodable: %w", err)
|
||||
}
|
||||
var src config.Source
|
||||
if err := json.Unmarshal(b, &src); err != nil {
|
||||
return nil, fmt.Errorf("source shape is invalid: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(src.Name) == "" {
|
||||
return nil, fmt.Errorf("source requires a name")
|
||||
}
|
||||
if strings.TrimSpace(src.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("source %s requires a base_url", src.Name)
|
||||
}
|
||||
if err := c.AddSource(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": src.Name}, nil
|
||||
}
|
||||
|
||||
// actionCreateKey creates a gateway key.
|
||||
//
|
||||
// The plaintext key is returned ONCE, in this response — the same posture as the
|
||||
// admin API. It is not retrievable later, so a plugin that needs it must store it
|
||||
// in its own credential vault immediately.
|
||||
func (c *Core) actionCreateKey(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
role, _ := args["role"].(string)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
models, err := decodeScopes(args["models"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err := c.CreateKey(name, role, models, "created by plugin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"key": k.Key,
|
||||
"name": k.Name,
|
||||
"role": k.Role,
|
||||
"models": k.Models,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- 破坏性实现 ----
|
||||
|
||||
func (c *Core) actionRemoveSource(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("remove_source requires a name")
|
||||
}
|
||||
// The last source is not removable: an empty registry turns every request
|
||||
// into a 503, and the operator would have to fix it from the filesystem.
|
||||
if len(c.Sources()) <= 1 {
|
||||
return nil, fmt.Errorf("refusing to remove the last source: the gateway would have nothing to route to")
|
||||
}
|
||||
if err := c.RemoveSource(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": name}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionDeleteKey(args map[string]interface{}) (interface{}, error) {
|
||||
key, _ := args["key"].(string)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("delete_key requires the key to delete")
|
||||
}
|
||||
ok, err := c.DeleteKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no such key")
|
||||
}
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionUpdateKey(args map[string]interface{}) (interface{}, error) {
|
||||
key, _ := args["key"].(string)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("update_key requires the key")
|
||||
}
|
||||
name, _ := args["name"].(string)
|
||||
role, _ := args["role"].(string)
|
||||
models, err := decodeScopes(args["models"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err := c.UpdateKey(key, name, role, models, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": k.Name, "role": k.Role}, nil
|
||||
}
|
||||
|
||||
// actionRemoveAdapter deletes an adapter, but only one the CALLER plugin owns —
|
||||
// the ownership check lives in Plugins.RemoveAdapter (see the adapter bridge).
|
||||
// A plugin can always remove its own; removing an operator's or a built-in is
|
||||
// refused there, and that refusal is the security property.
|
||||
func (c *Core) actionRemoveAdapter(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("remove_adapter requires a name")
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if ps == nil {
|
||||
return nil, fmt.Errorf("no plugins loaded")
|
||||
}
|
||||
plugin, _ := args["plugin"].(string)
|
||||
if plugin == "" {
|
||||
return nil, fmt.Errorf("remove_adapter needs the calling plugin's name")
|
||||
}
|
||||
if err := ps.RemoveAdapter(plugin, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": name}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionSetPluginEnabled(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("set_plugin_enabled requires a name")
|
||||
}
|
||||
enabled, ok := args["enabled"].(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("set_plugin_enabled requires enabled (boolean)")
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if ps == nil {
|
||||
return nil, fmt.Errorf("no plugins loaded")
|
||||
}
|
||||
if err := ps.SetEnabled(name, enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": name, "enabled": enabled}, nil
|
||||
}
|
||||
|
||||
// decodeScopes converts a Lua-decoded array of model-scope tables into
|
||||
// []config.ModelScope. It accepts both an explicit array and a single table so a
|
||||
// one-entry chain does not need a wrapper.
|
||||
func decodeScopes(v interface{}) ([]config.ModelScope, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entries are not encodable: %w", err)
|
||||
}
|
||||
var list []config.ModelScope
|
||||
if err := json.Unmarshal(b, &list); err == nil {
|
||||
return list, nil
|
||||
}
|
||||
var one config.ModelScope
|
||||
if err := json.Unmarshal(b, &one); err != nil {
|
||||
return nil, fmt.Errorf("entries shape is invalid: %w", err)
|
||||
}
|
||||
return []config.ModelScope{one}, nil
|
||||
}
|
||||
@ -1,373 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// 本文件验证 R4-c/R4-d 的闭环:**插件自己驱动外部源的链接认证,然后把源接进内核**。
|
||||
//
|
||||
// 这是用户点明的"同类插件"能力(对照 dsh-our-free-model):插件主导 OAuth 登录、
|
||||
// 存凭据、定时领额度、把拿到的端点注册成内核上游。此前 llmsproxy 的插件只能管
|
||||
// 自己的适配器,够不到内核。
|
||||
|
||||
// kernelTestCore 起一个最小 core(一个源 + 一个 key)。
|
||||
func kernelTestCore(t *testing.T) *Core {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
// 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
|
||||
// MODELROUTER_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
|
||||
// /etc/modelrouter/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
|
||||
// 把 26 个测试账号写进了生产 .channels-store.json)。
|
||||
// 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
|
||||
if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" {
|
||||
t.Setenv("MODELROUTER_PLUGIN_DIR", pluginDir)
|
||||
}
|
||||
// ★ 显示器号与端口用**高段号**,避开生产。
|
||||
//
|
||||
// 真实事故(2026-10-15):生产 unit 带 `PrivateTmp=yes`,它起的 Xvfb :91 的
|
||||
// 锁文件在**服务自己的 /tmp** 里 —— 宿主上的测试看不到它,于是判"91 空闲"
|
||||
// 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着
|
||||
// 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。
|
||||
// 故测试整体挪到 300+。
|
||||
if os.Getenv("MODELROUTER_XDISPLAY_BASE") == "" {
|
||||
t.Setenv("MODELROUTER_XDISPLAY_BASE", "300")
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
PluginDir: pluginDir,
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-kernel-0001"},
|
||||
Sources: []config.Source{{
|
||||
Name: "primary", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
APIKey: "secret-primary-key", MaxConcurrent: 1,
|
||||
Models: []config.Model{{ID: "m1", Kind: "chat"}},
|
||||
}},
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 测试结束**必须**把插件拉起的服务端浏览器栈收掉。
|
||||
// 为何必需:callback 流(trae/gemini/有道)在 start_login 时就起一整套
|
||||
// Xvfb+chromium+x11vnc+websockify,而绝大多数测试不会显式 stop_browser、
|
||||
// 会话 TTL 也还没到 —— 于是一轮 core 全包跑完就攒出 7 套常驻进程
|
||||
// (实测 websockify×7 + x11vnc×7 + 7 个 profile 目录),把显示器号与端口
|
||||
// 段逐步耗光,后续测试报「chromium/websockify 端口未就绪」。
|
||||
// 在这一处统一收掉,比让每个测试各自记得更可靠。
|
||||
t.Cleanup(func() {
|
||||
ps := c.Plugins()
|
||||
if ps == nil {
|
||||
return
|
||||
}
|
||||
for _, name := range []string{"channels"} {
|
||||
_, _ = ps.InvokeHandler(name, "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
})
|
||||
}
|
||||
})
|
||||
t.Cleanup(c.Close)
|
||||
return c
|
||||
}
|
||||
|
||||
// grant 给插件授权若干动作(模拟运维在设置页点选)。
|
||||
func grant(t *testing.T, c *Core, plugin string, actions ...string) {
|
||||
t.Helper()
|
||||
if err := c.Plugins().SetPluginKernelPermission(plugin, actions, false); err != nil {
|
||||
t.Fatalf("grant %v to %s: %v", actions, plugin, err)
|
||||
}
|
||||
}
|
||||
|
||||
const kernelProbePlugin = `
|
||||
local plugin = { name = "kernelprobe", version = "1.0" }
|
||||
plugin.exports = { "read_status", "read_models", "read_sources", "try_delete_key", "probe_count", "call_reload" }
|
||||
plugin.kernel = { "status", "list_models", "reload" }
|
||||
|
||||
function plugin.read_status()
|
||||
local st, err = __llmsproxy_kernel.call("status", {})
|
||||
if err then return { err = err } end
|
||||
return { ready = st.ready, nsources = #(st.sources or {}) }
|
||||
end
|
||||
|
||||
function plugin.read_models()
|
||||
local m, err = __llmsproxy_kernel.call("list_models", {})
|
||||
if err then return { err = err } end
|
||||
return { models = m }
|
||||
end
|
||||
|
||||
-- sources 动作返回的密钥必须是掩码的
|
||||
function plugin.read_sources()
|
||||
local s, err = __llmsproxy_kernel.call("list_sources", {})
|
||||
if err then return { err = err } end
|
||||
return { sources = s }
|
||||
end
|
||||
|
||||
-- 未授权的破坏性动作必须被拒
|
||||
function plugin.try_delete_key()
|
||||
local _, err = __llmsproxy_kernel.call("delete_key", { key = "sk-kernel-0001" })
|
||||
return { err = err }
|
||||
end
|
||||
|
||||
-- 授权数量可见(插件据此优雅降级)
|
||||
function plugin.probe_count()
|
||||
local acts = __llmsproxy_kernel.actions()
|
||||
local granted = 0
|
||||
for _, a in ipairs(acts) do
|
||||
if a.granted then granted = granted + 1 end
|
||||
end
|
||||
return { total = #acts, granted = granted }
|
||||
end
|
||||
|
||||
function plugin.call_reload()
|
||||
local r, err = __llmsproxy_kernel.call("reload", {})
|
||||
return { ok = r and r.ok, err = err }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
|
||||
// TestPluginCallsKernelActions 验证插件能调内核动作,且权限边界成立。
|
||||
func TestPluginCallsKernelActions(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "status", "list_models", "list_sources")
|
||||
|
||||
// 只读动作可用
|
||||
out, err := c.Plugins().Call("kernelprobe", "read_status", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("read_status: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["err"] != nil {
|
||||
t.Fatalf("status 调用失败: %v", m["err"])
|
||||
}
|
||||
if m["ready"] != true {
|
||||
t.Errorf("registry 应已就绪: %#v", m)
|
||||
}
|
||||
if n, _ := m["nsources"].(float64); n < 1 {
|
||||
t.Errorf("应至少有 1 个源, 实得 %v", m["nsources"])
|
||||
}
|
||||
|
||||
// 模型列表
|
||||
out, err = c.Plugins().Call("kernelprobe", "read_models", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if models, ok := out.(map[string]interface{})["models"].([]interface{}); !ok || len(models) == 0 {
|
||||
t.Errorf("模型列表应为非空数组: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionMasksSourceKeys 是安全断言:list_sources **不得**泄漏 api_key。
|
||||
func TestKernelActionMasksSourceKeys(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "list_sources")
|
||||
|
||||
out, err := c.Plugins().Call("kernelprobe", "read_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把整个返回值序列化后检查:明文密钥绝不能出现在里面
|
||||
b, _ := json.Marshal(out)
|
||||
if s := string(b); json.Valid(b) && contains(s, "secret-primary-key") {
|
||||
t.Fatalf("list_sources 泄漏了源的明文 api_key: %s", s)
|
||||
}
|
||||
// 但应有可区分性(has_key + 掩码后缀)
|
||||
if !contains(string(b), "***") {
|
||||
t.Errorf("应给出掩码提示而不是什么都没有: %s", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionDeniedWithoutGrant 是权限默认拒绝的断言。
|
||||
func TestKernelActionDeniedWithoutGrant(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 故意不授权任何动作
|
||||
out, err := c.Plugins().Call("kernelprobe", "try_delete_key", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("调用本身不该报错(拒绝应作为返回值): %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["err"] == nil || m["err"] == "" {
|
||||
t.Fatal("未授权的 delete_key 必须被拒")
|
||||
}
|
||||
t.Logf("拒绝信息: %v", m["err"])
|
||||
// 拒绝信息要点名动作与插件(否则运维无法行动)
|
||||
msg, _ := m["err"].(string)
|
||||
if !contains(msg, "delete_key") || !contains(msg, "kernelprobe") {
|
||||
t.Errorf("拒绝信息应点名动作与插件, 实得 %q", msg)
|
||||
}
|
||||
// 且密钥必须还在
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); !ok {
|
||||
t.Fatal("被拒的动作不应真的执行")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionsReportGranted 插件能看到自己拿到了哪些权限。
|
||||
func TestKernelActionsReportGranted(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "status", "list_models")
|
||||
|
||||
out, err := c.Plugins().Call("kernelprobe", "probe_count", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
total, _ := m["total"].(float64)
|
||||
granted, _ := m["granted"].(float64)
|
||||
if total < 15 {
|
||||
t.Errorf("动作总数应 >=15, 实得 %v", total)
|
||||
}
|
||||
if granted != 2 {
|
||||
t.Errorf("授权数应为 2(status+list_models), 实得 %v", granted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginRegistersSourceIntoKernel 是"同类插件"的核心闭环:
|
||||
// 插件通过内核动作把一个新的外部源接进网关。
|
||||
func TestPluginRegistersSourceIntoKernel(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
code := `
|
||||
local plugin = { name = "upstream", version = "1.0" }
|
||||
plugin.exports = { "add_my_source" }
|
||||
plugin.kernel = { "add_source", "list_sources" }
|
||||
|
||||
function plugin.add_my_source(args)
|
||||
local src = {
|
||||
name = "plugin-provided",
|
||||
base_url = "http://127.0.0.1:9/v1",
|
||||
adapter = "openai",
|
||||
api_key = "plugin-managed-token",
|
||||
max_concurrent = 2,
|
||||
models = { { id = "plugin-model", kind = "chat", priority = 10 } },
|
||||
}
|
||||
local r, err = __llmsproxy_kernel.call("add_source", { source = src })
|
||||
if err then return { err = err } end
|
||||
return { ok = r.ok, name = r.name }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("upstream", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "upstream", "add_source", "list_sources")
|
||||
|
||||
out, err := c.Plugins().Call("upstream", "add_my_source", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("add_source: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["err"] != nil {
|
||||
t.Fatalf("插件注册源失败: %v", m["err"])
|
||||
}
|
||||
if m["name"] != "plugin-provided" {
|
||||
t.Fatalf("返回的源名不对: %#v", m)
|
||||
}
|
||||
|
||||
// 内核侧必须真的有了这个源 —— 这是"接进网关"的定义
|
||||
found := false
|
||||
for _, s := range c.Sources() {
|
||||
if s.Name == "plugin-provided" {
|
||||
found = true
|
||||
if s.BaseURL != "http://127.0.0.1:9/v1" {
|
||||
t.Errorf("base_url 未落库: %q", s.BaseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("插件注册的源没有出现在内核源列表里")
|
||||
}
|
||||
// 且模型已可路由
|
||||
for _, id := range c.Registry().ModelList() {
|
||||
if id == "plugin-model" {
|
||||
return // 通过
|
||||
}
|
||||
}
|
||||
t.Errorf("插件注册的模型未进入可路由列表: %v", c.Registry().ModelList())
|
||||
}
|
||||
|
||||
// TestKernelPermissionPersists 授权必须落盘(否则重启即失效)。
|
||||
func TestKernelPermissionPersists(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "status", "reload")
|
||||
|
||||
pdir := c.Config().PluginDir
|
||||
path := filepath.Join(pdir, ".kernel-perms.json")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("授权未落盘: %v", err)
|
||||
}
|
||||
if !contains(string(b), "reload") {
|
||||
t.Errorf("落盘内容缺少动作名: %s", b)
|
||||
}
|
||||
|
||||
// 模拟重启:新建 Plugins、加载授权、权限仍在
|
||||
vm2 := lua.NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm2.Stop()
|
||||
ps2 := lua.NewPlugins(vm2, pdir)
|
||||
if err := ps2.LoadKernelPermissions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perm := ps2.PluginKernelPermission("kernelprobe")
|
||||
if len(perm.Actions) != 2 {
|
||||
t.Fatalf("重启后授权应保留 2 项, 实得 %#v", perm.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownKernelActionRejected 拼错的动作名必须在授权时就被拒,
|
||||
// 而不是静默授予"什么都不允许"。
|
||||
func TestUnknownKernelActionRejected(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Plugins().SetPluginKernelPermission("kernelprobe", []string{"no_such_action"}, false); err == nil {
|
||||
t.Fatal("未知动作名应被拒")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionSurfaceExcludesPlaintextKeys 是凭据边界的回归断言:
|
||||
// 动作面里绝不能出现返回明文密钥的动作(ListKeys/FindKey 被有意排除)。
|
||||
func TestKernelActionSurfaceExcludesPlaintextKeys(t *testing.T) {
|
||||
for _, a := range lua.KernelActions() {
|
||||
switch a.Name {
|
||||
case "list_keys", "find_key", "get_key":
|
||||
t.Fatalf("动作面出现了明文密钥读取动作 %q —— 这会把部署的凭据面交给插件", a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return len(needle) > 0 && len(haystack) >= len(needle) &&
|
||||
func() bool {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}()
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestGrantedDestructiveActionTakesEffect 确认授权后的破坏性动作**真的执行**。
|
||||
//
|
||||
// 上一条测试只证明"不死锁、有返回"。一个把所有动作都变成空转的 dispatch 也能
|
||||
// 通过它 —— 所以这条单独断言副作用确实落地(密钥真的被删)。
|
||||
func TestGrantedDestructiveActionTakesEffect(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
code := `
|
||||
local plugin = { name = "eff", version = "1.0" }
|
||||
plugin.exports = { "del" }
|
||||
function plugin.del(args)
|
||||
local r, err = __llmsproxy_kernel.call("delete_key", { key = args.key })
|
||||
return { ok = r ~= nil, err = err }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("eff", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = c.Plugins().SetPluginKernelPermission("eff", []string{"delete_key"}, false)
|
||||
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); !ok {
|
||||
t.Fatal("前置条件:key 应存在")
|
||||
}
|
||||
out, err := c.Plugins().Call("eff", "del", map[string]interface{}{"key": "sk-kernel-0001"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("动作未成功: %#v", m)
|
||||
}
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); ok {
|
||||
t.Fatal("动作报告成功但副作用没发生 —— dispatch 是空转")
|
||||
}
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// TestNoKernelActionDeadlocksFromLua 对**每个**内核动作做同一条断言:
|
||||
// 在插件 Lua 调用(此时持有 p.mu)内执行时不得死锁。
|
||||
//
|
||||
// 为什么是"每个"而不是"那个出问题的":最初只有 set_plugin_enabled 被发现自死锁
|
||||
// (它经 SetEnabled 重入 p.mu),但"是否还有别的动作也会重入"只能靠枚举回答。
|
||||
// 这条测试就是那次枚举的固化 —— 将来新增动作时如果也重入插件锁,它会失败。
|
||||
//
|
||||
// 同时它验证了"超时后副作用仍在幕后执行"这个更隐蔽的变体:那个缺陷的表现是
|
||||
// 插件收到 error、但动作其实生效了。断言方式只是"不死锁 + 返回",因为死锁与
|
||||
// 迟到副作用是同一个根因(在错误的锁上下文里执行)。
|
||||
func TestNoKernelActionDeadlocksFromLua(t *testing.T) {
|
||||
for _, a := range lua.KernelActions() {
|
||||
a := a
|
||||
t.Run(a.Name, func(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
code := `
|
||||
local plugin = { name = "probe_all_actions", version = "1.0" }
|
||||
plugin.exports = { "run" }
|
||||
function plugin.run(args)
|
||||
local r, err = __llmsproxy_kernel.call(args.action, args.arg or {})
|
||||
return { ok = r ~= nil, err = err }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("probe_all_actions", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 授权所有动作(含破坏性),以便真正执行到
|
||||
all := make([]string, 0)
|
||||
for _, x := range lua.KernelActions() {
|
||||
all = append(all, x.Name)
|
||||
}
|
||||
if err := c.Plugins().SetPluginKernelPermission("probe_all_actions", all, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arg := map[string]interface{}{}
|
||||
switch a.Name {
|
||||
case "name", "adapter_owner":
|
||||
arg["name"] = "primary"
|
||||
}
|
||||
if a.Name == "adapter_owner" {
|
||||
arg = map[string]interface{}{"name": "openai"}
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, err := c.Plugins().Call("probe_all_actions", "run",
|
||||
map[string]interface{}{"action": a.Name, "arg": arg})
|
||||
if err != nil {
|
||||
t.Logf("%s 返回 err=%v", a.Name, err)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatalf("动作 %s 在 Lua 调用内死锁(deferred=%v danger=%s)", a.Name, a.Deferred, a.Danger)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// AutoChainFor sits on the request hot path and reads keyAutoChains with no
|
||||
// lock, while SaveKeyAuto / rebuildRegistry replace that map from the admin
|
||||
// path. A "call it twice" test never interleaves those, so it would pass even
|
||||
// if the map were mutated in place. These tests run both sides at once under
|
||||
// -race and assert what readers actually observe.
|
||||
//
|
||||
// The property: a reader sees a chain that is either the key's own or the
|
||||
// global one — never a torn or empty one. If rebuildKeyAutoChains built the
|
||||
// map in place instead of swapping a fresh pointer, readers would sometimes
|
||||
// see a partially-filled map and silently fall back to the global chain, i.e.
|
||||
// a user quietly losing their configured chain under load.
|
||||
|
||||
// concurrentChainConfig builds N keys, half with their own chain pointing at a
|
||||
// distinct model so a fallback to the global chain is distinguishable.
|
||||
func concurrentChainConfig(t *testing.T, n int) *config.Config {
|
||||
t.Helper()
|
||||
cfg := perKeyTestConfig(t)
|
||||
cfg.Keys = nil
|
||||
cfg.GatewayKeys = []string{"sk-gw-admin"}
|
||||
cfg.Auto = []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}}
|
||||
for i := 0; i < n; i++ {
|
||||
k := config.GWKey{
|
||||
Key: fmt.Sprintf("sk-gw-k%03d", i),
|
||||
Role: "user",
|
||||
Name: fmt.Sprintf("user%d", i),
|
||||
}
|
||||
if i%2 == 0 {
|
||||
k.Auto = []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}
|
||||
}
|
||||
cfg.Keys = append(cfg.Keys, k)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestAutoChainForConcurrentWithSaveKeyAuto(t *testing.T) {
|
||||
cfg := concurrentChainConfig(t, 40)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
var bad atomic.Int64
|
||||
|
||||
for r := 0; r < 8; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
for i := 0; i < 40; i++ {
|
||||
key := fmt.Sprintf("sk-gw-k%03d", i)
|
||||
ch, ok := c.AutoChainFor(key)
|
||||
if !ok || ch == nil || len(ch.Tiers) == 0 {
|
||||
bad.Add(1)
|
||||
continue
|
||||
}
|
||||
// Legal values are this key's own chain model (either
|
||||
// side of the writer's flip) or the global chain's. The
|
||||
// assertion is about the legal set, not a fixed value:
|
||||
// the writers legitimately change the chain underneath.
|
||||
got := ch.Tiers[0].Slots[0].Model
|
||||
if got != "gpt-4o" && got != "gpt-4o-mini" {
|
||||
bad.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for w := 0; w < 2; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
model := "gpt-4o-mini"
|
||||
for i := 0; i < 25 && !stop.Load(); i++ {
|
||||
for j := 0; j < 40; j += 2 {
|
||||
if err := c.SaveKeyAuto(fmt.Sprintf("sk-gw-k%03d", j), []config.ModelScope{
|
||||
{Model: model, Source: "s1", Tier: 1},
|
||||
}); err != nil {
|
||||
bad.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
if model == "gpt-4o-mini" {
|
||||
model = "gpt-4o"
|
||||
} else {
|
||||
model = "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
|
||||
if n := bad.Load(); n > 0 {
|
||||
t.Fatalf("%d reads saw a chain outside {own, global} — the cache is "+
|
||||
"being swapped in place instead of atomically", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The registry rebuild path (AddSource / RemoveSource) also replaces the
|
||||
// cache, so readers must keep a usable chain throughout.
|
||||
//
|
||||
// Churned sources use a "churn" prefix on purpose: deleting a source a chain
|
||||
// references legitimately empties that chain (BuildChain drops unresolvable
|
||||
// slots), and asserting otherwise would be testing a fiction rather than the
|
||||
// code.
|
||||
func TestAutoChainForConcurrentWithSourceChanges(t *testing.T) {
|
||||
cfg := concurrentChainConfig(t, 20)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
var bad atomic.Int64
|
||||
|
||||
for r := 0; r < 6; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for !stop.Load() {
|
||||
for i := 0; i < 20; i++ {
|
||||
ch, ok := c.AutoChainFor(fmt.Sprintf("sk-gw-k%03d", i))
|
||||
if !ok || ch == nil || len(ch.Tiers) == 0 {
|
||||
bad.Add(1)
|
||||
continue
|
||||
}
|
||||
if got := ch.Tiers[0].Slots[0].Model; got != "gpt-4o" && got != "gpt-4o-mini" {
|
||||
bad.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for n := 0; n < 30 && !stop.Load(); n++ {
|
||||
name := fmt.Sprintf("churn%d", n)
|
||||
_ = c.AddSource(config.Source{
|
||||
Name: name, BaseURL: "http://127.0.0.1:9/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m-" + name}}},
|
||||
)
|
||||
_ = c.RemoveSource(name)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
|
||||
if n := bad.Load(); n > 0 {
|
||||
t.Fatalf("%d reads lost the chain while unrelated sources were churned", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Many distinct keys: the lookup must stay a map hit and must not degrade into
|
||||
// returning the global chain as the key count grows.
|
||||
func TestAutoChainForManyKeys(t *testing.T) {
|
||||
cfg := concurrentChainConfig(t, 500)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
for i := 0; i < 500; i++ {
|
||||
key := fmt.Sprintf("sk-gw-k%03d", i)
|
||||
ch, _ := c.AutoChainFor(key)
|
||||
if ch == nil || len(ch.Tiers) == 0 {
|
||||
t.Fatalf("key %s lost its chain with 500 keys configured", key)
|
||||
}
|
||||
want := "gpt-4o"
|
||||
if i%2 == 0 {
|
||||
want = "gpt-4o-mini"
|
||||
}
|
||||
if got := ch.Tiers[0].Slots[0].Model; got != want {
|
||||
t.Fatalf("key %s got %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,221 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/scheduler"
|
||||
)
|
||||
|
||||
// Per-key AUTO chains let an admin give one user their own scheduling chain
|
||||
// while every other key keeps the global one. These tests pin the three
|
||||
// properties that make that safe:
|
||||
//
|
||||
// 1. a key WITH its own chain gets it;
|
||||
// 2. a key WITHOUT one inherits the global chain (backward compatible);
|
||||
// 3. the override is actually persisted, so it survives a restart.
|
||||
//
|
||||
// A test that only checked (1) would pass even if AutoChainFor ignored the key
|
||||
// and always returned the global chain whenever the two happened to be equal,
|
||||
// so (2) uses deliberately *different* chains.
|
||||
|
||||
func perKeyTestConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-gw-admin"},
|
||||
Sources: []config.Source{
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}},
|
||||
},
|
||||
// Global chain points at gpt-4o.
|
||||
Auto: []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}},
|
||||
Keys: []config.GWKey{
|
||||
{Key: "sk-gw-admin", Role: "admin"},
|
||||
{Key: "sk-gw-inherits", Role: "user", Name: "inherits"},
|
||||
// This key's own chain points at a DIFFERENT model, so returning
|
||||
// the global chain by mistake is detectable.
|
||||
{Key: "sk-gw-own", Role: "user", Name: "own",
|
||||
Auto: []config.ModelScope{{Model: "gpt-4o-mini", Source: "s1", Tier: 1}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func firstSlotModel(t *testing.T, c *Core, key string) string {
|
||||
t.Helper()
|
||||
ch, _ := c.AutoChainFor(key)
|
||||
if ch == nil || len(ch.Tiers) == 0 {
|
||||
t.Fatalf("key %s: no chain tiers", key)
|
||||
}
|
||||
slots := ch.Tiers[0].Slots
|
||||
if len(slots) == 0 {
|
||||
t.Fatalf("key %s: tier 1 has no slots", key)
|
||||
}
|
||||
return slots[0].Model
|
||||
}
|
||||
|
||||
// A key that declares its own chain must be scheduled by it, not the global.
|
||||
func TestAutoChainForUsesKeyOwnChain(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o-mini" {
|
||||
t.Fatalf("key with own chain must use it, got %q (want gpt-4o-mini)", got)
|
||||
}
|
||||
// Sanity: the global chain really is different, so the assertion above
|
||||
// cannot pass by accident.
|
||||
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
|
||||
t.Fatalf("admin must use global chain, got %q (want gpt-4o)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A key with no own chain inherits the global one. This is what keeps every
|
||||
// pre-existing deployment working with no config change.
|
||||
func TestAutoChainForInheritsGlobalWhenUnset(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o" {
|
||||
t.Fatalf("key without own chain must inherit global, got %q (want gpt-4o)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A key that is not in the key table at all (e.g. a seed key) must also
|
||||
// inherit the global chain rather than erroring or returning nil.
|
||||
func TestAutoChainForUnknownKeyInheritsGlobal(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
ch, ok := c.AutoChainFor("sk-gw-never-configured")
|
||||
if !ok || ch == nil || len(ch.Tiers) == 0 {
|
||||
t.Fatalf("unknown key must fall back to global chain, got ok=%v chain=%v", ok, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-key chain must survive a save/reload round trip: an admin configuring
|
||||
// a chain in the WebUI and restarting the gateway must not silently lose it.
|
||||
func TestSaveKeyAutoPersistsAndApplies(t *testing.T) {
|
||||
cfg := perKeyTestConfig(t)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
if err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
|
||||
{Model: "gpt-4o-mini", Source: "s1", Tier: 1},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveKeyAuto: %v", err)
|
||||
}
|
||||
if got := firstSlotModel(t, c, "sk-gw-inherits"); got != "gpt-4o-mini" {
|
||||
t.Fatalf("saved chain must apply immediately, got %q", got)
|
||||
}
|
||||
|
||||
// Reload from disk exactly like a restart does.
|
||||
reloaded, err := config.Load(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
c2 := newTestCore(t, reloaded)
|
||||
if got := firstSlotModel(t, c2, "sk-gw-inherits"); got != "gpt-4o-mini" {
|
||||
t.Fatalf("per-key chain must survive restart, got %q", got)
|
||||
}
|
||||
// The other key must be unaffected by its neighbour's override.
|
||||
if got := firstSlotModel(t, c2, "sk-gw-admin"); got != "gpt-4o" {
|
||||
t.Fatalf("override must not leak to other keys, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing the override must return the key to the global chain, both in
|
||||
// memory and after a restart. This is what the WebUI's "use global" toggle
|
||||
// does, so a stale shadowing entry would silently pin the user to their old
|
||||
// chain forever.
|
||||
func TestSaveKeyAutoClearRestoresGlobal(t *testing.T) {
|
||||
cfg := perKeyTestConfig(t)
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
if err := c.SaveKeyAuto("sk-gw-own", nil); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if got := firstSlotModel(t, c, "sk-gw-own"); got != "gpt-4o" {
|
||||
t.Fatalf("cleared key must inherit global again, got %q", got)
|
||||
}
|
||||
|
||||
reloaded, err := config.Load(cfg.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
c2 := newTestCore(t, reloaded)
|
||||
if got := firstSlotModel(t, c2, "sk-gw-own"); got != "gpt-4o" {
|
||||
t.Fatalf("clear must survive restart, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Saving an unknown key must fail loudly rather than creating a shadow entry
|
||||
// that no request can ever match.
|
||||
func TestSaveKeyAutoUnknownKeyFails(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
if err := c.SaveKeyAuto("sk-gw-nope", []config.ModelScope{{Model: "gpt-4o", Source: "s1"}}); err == nil {
|
||||
t.Fatal("SaveKeyAuto on unknown key must return an error")
|
||||
}
|
||||
}
|
||||
|
||||
// Per-key quota validation must run, exactly like the model scope path: an
|
||||
// override is another place an operator can write a bad budget.
|
||||
func TestSaveKeyAutoRejectsBadQuota(t *testing.T) {
|
||||
c := newTestCore(t, perKeyTestConfig(t))
|
||||
|
||||
err := c.SaveKeyAuto("sk-gw-inherits", []config.ModelScope{
|
||||
{Model: "gpt-4o", Source: "s1", TokenQuota: -5},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("negative token quota in a per-key auto chain must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Source changes must recompile per-key chains. A key whose chain names a
|
||||
// model that does not exist yet compiles to an empty chain (BuildChain drops
|
||||
// slots it cannot resolve — sending the request into a black hole would be
|
||||
// worse), and must start working as soon as the source appears.
|
||||
func TestKeyAutoChainsRebuildOnSourceChange(t *testing.T) {
|
||||
cfg := perKeyTestConfig(t)
|
||||
cfg.Keys = append(cfg.Keys, config.GWKey{
|
||||
Key: "sk-gw-late", Role: "user", Name: "late",
|
||||
Auto: []config.ModelScope{{Model: "new-model", Source: "s2", Tier: 1}},
|
||||
})
|
||||
c := newTestCore(t, cfg)
|
||||
|
||||
// s2 does not exist yet, so the chain has no usable slot.
|
||||
if ch, _ := c.AutoChainFor("sk-gw-late"); chainSlotsOf(ch) != 0 {
|
||||
t.Fatalf("chain naming an unknown model must compile empty, got %d slots",
|
||||
chainSlotsOf(ch))
|
||||
}
|
||||
// The other keys are unaffected by the broken one.
|
||||
if got := firstSlotModel(t, c, "sk-gw-admin"); got != "gpt-4o" {
|
||||
t.Fatalf("broken per-key chain must not affect others, got %q", got)
|
||||
}
|
||||
|
||||
// Add the source; the per-key chain must pick it up without a restart.
|
||||
cfg.Sources = append(cfg.Sources, config.Source{
|
||||
Name: "s2", BaseURL: "http://127.0.0.1:2/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "new-model"}},
|
||||
})
|
||||
if err := c.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
if got := firstSlotModel(t, c, "sk-gw-late"); got != "new-model" {
|
||||
t.Fatalf("per-key chain must resolve after the source is added, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// chainSlotsOf counts usable slots, tolerating a nil chain.
|
||||
func chainSlotsOf(ch *scheduler.Chain) int {
|
||||
if ch == nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, tn := range ch.Tiers {
|
||||
n += len(tn.Slots)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 把测试进程的时区钉成 **UTC**。
|
||||
//
|
||||
// 为什么必需:Qoder 的每日活动按 **UTC+8** 结算,判定必须用算术平移而不是
|
||||
// 本机时区。但这台机器**本身就是 HKT(+0800)** —— 于是在真实环境里
|
||||
// 「os.date("!*t")(UTC 平移)」与「os.date("*t")(本机时区)」结果**完全相同**,
|
||||
// 判据无法区分二者(实测:把实现改成用本机时区,判据照样通过)。
|
||||
// 钉成 UTC 后两者相差 8 小时,误用本机时区的实现立刻暴露。
|
||||
//
|
||||
// 只影响测试进程,不改系统时区。
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv("TZ", "UTC")
|
||||
time.Local = time.UTC
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// The plugin outbound allowlist is built from the hosts of configured sources.
|
||||
// That rule is correct for every adapter whose credential and endpoint live on
|
||||
// the same host — which was all of them until OpenDesign.
|
||||
//
|
||||
// OpenDesign splits them: inference is amr-link.open-design.ai, billing is
|
||||
// amr-api.open-design.ai. The quota plugin derives the billing host from the
|
||||
// inference one, so the host it asks for is NOT the host the operator
|
||||
// configured. The request is then refused by the allowlist with a message that
|
||||
// reads like a plugin defect ("host ... is not an allowed outbound destination")
|
||||
// rather than a missing entry.
|
||||
//
|
||||
// This file pins the bridge: when a source looks like OpenDesign, the derived
|
||||
// billing host joins the allowlist.
|
||||
|
||||
func TestSourceHostsIncludesConfiguredHosts(t *testing.T) {
|
||||
cfg := &config.Config{Sources: []config.Source{
|
||||
{Name: "a", BaseURL: "https://api.deepseek.com"},
|
||||
{Name: "b", BaseURL: "https://amr-link.open-design.ai/v1"},
|
||||
{Name: "empty", BaseURL: ""},
|
||||
{Name: "bad", BaseURL: "://nonsense"},
|
||||
}}
|
||||
got := map[string]bool{}
|
||||
for _, h := range sourceHosts(cfg) {
|
||||
got[h] = true
|
||||
}
|
||||
for _, want := range []string{"api.deepseek.com", "amr-link.open-design.ai"} {
|
||||
if !got[want] {
|
||||
t.Errorf("sourceHosts missing configured host %q (allowlist would refuse "+
|
||||
"every plugin request to it): %v", want, got)
|
||||
}
|
||||
}
|
||||
// An empty/unparseable base_url must not abort the whole list.
|
||||
if !got["api.deepseek.com"] {
|
||||
t.Fatal("an unparseable sibling source dropped a valid host")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceHostsAddsDerivedBillingHost covers the OpenDesign case at the level
|
||||
// that actually matters: the host the PLUGIN asks for must be in the allowlist.
|
||||
func TestSourceHostsAddsDerivedBillingHost(t *testing.T) {
|
||||
cfg := &config.Config{Sources: []config.Source{
|
||||
{Name: "vela", BaseURL: "https://amr-link.open-design.ai/v1"},
|
||||
}}
|
||||
got := map[string]bool{}
|
||||
for _, h := range sourceHosts(cfg) {
|
||||
got[h] = true
|
||||
}
|
||||
if !got["amr-link.open-design.ai"] {
|
||||
t.Fatalf("inference host missing: %v", got)
|
||||
}
|
||||
if !got["amr-api.open-design.ai"] {
|
||||
t.Fatalf("the billing host the quota plugin derives is not in the allowlist, "+
|
||||
"so every OpenDesign balance probe is refused before it leaves the "+
|
||||
"process: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no OpenDesign source must not silently gain its billing
|
||||
// host: an allowlist entry is a grant, and widening it for everyone would let an
|
||||
// unrelated plugin reach a host the operator never configured.
|
||||
func TestSourceHostsDoesNotGrantUnconfiguredBillingHost(t *testing.T) {
|
||||
cfg := &config.Config{Sources: []config.Source{
|
||||
{Name: "ds", BaseURL: "https://api.deepseek.com"},
|
||||
}}
|
||||
for _, h := range sourceHosts(cfg) {
|
||||
if h == "amr-api.open-design.ai" {
|
||||
t.Fatal("the OpenDesign billing host was granted to a deployment with " +
|
||||
"no OpenDesign source")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The derivation has to agree with the Lua side. Keep the two spellings of the
|
||||
// rule in one place so a change to one is visible when reading the other.
|
||||
func TestSourceHostsDerivationMatchesPluginRule(t *testing.T) {
|
||||
// what the Lua adapter does: origin:gsub("^amr%-link%.", "amr-api.")
|
||||
const in = "amr-link.open-design.ai"
|
||||
want := "amr-api.open-design.ai"
|
||||
got := deriveBillingHost(in)
|
||||
if got != want {
|
||||
t.Fatalf("deriveBillingHost(%q) = %q, want %q — the Go allowlist and the "+
|
||||
"Lua probe must agree on the host or the request is refused", in, got, want)
|
||||
}
|
||||
// A host that is not amr-link stays untouched, so this cannot accidentally
|
||||
// rewrite an unrelated provider into an OpenDesign address.
|
||||
for _, other := range []string{"api.deepseek.com", "amr-link.example.com", "xamr-link.a"} {
|
||||
if deriveBillingHost(other) != other {
|
||||
t.Errorf("deriveBillingHost(%q) = %q, want it unchanged", other, deriveBillingHost(other))
|
||||
}
|
||||
}
|
||||
// url.Parse round-trip keeps the helper honest about what it receives.
|
||||
u, err := url.Parse("https://amr-link.open-design.ai/v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deriveBillingHost(u.Hostname()) != "amr-api.open-design.ai" {
|
||||
t.Fatal("derivation failed on a real base_url hostname")
|
||||
}
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// Startup must be idempotent, and every credential consumer must see PLAINTEXT.
|
||||
//
|
||||
// Two bugs shared one root cause: NewFromConfig unsealed the config at the END,
|
||||
// after the steps that read credentials had already run.
|
||||
//
|
||||
// 1. seedKeys() deduped cfg.Keys[i].Key against the plaintext gateway_keys
|
||||
// entries. With ciphertext keys the comparison never matched, so every
|
||||
// restart appended another copy of the same admin key — production reached
|
||||
// four identical admin keys.
|
||||
// 2. rebuildRegistry() handed cfg.Sources[i].APIKey to provider.New, so with
|
||||
// ciphertext it built every upstream client with "enc:v1:..." as its bearer
|
||||
// token and every upstream call would 401.
|
||||
//
|
||||
// They masked each other: seedKeys' Save() unsealed memory as a side effect, so
|
||||
// bug 2 was invisible until the redundant saves were removed. These tests drive
|
||||
// the real entry point (core.New -> config.Load off disk), because building a
|
||||
// fresh in-memory config per attempt hides both bugs — that is exactly how the
|
||||
// first draft of this test failed to reproduce anything.
|
||||
|
||||
// writeSealedInstall creates a config on disk the way a real install looks
|
||||
// after its first run: credentials already sealed, gateway_keys still plaintext.
|
||||
func writeSealedInstall(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
cfg := &config.Config{
|
||||
Path: path,
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-gw-seed-me"},
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
|
||||
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}},
|
||||
}},
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("initial install: %v", err)
|
||||
}
|
||||
c.Close()
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "enc:v1:") {
|
||||
t.Fatalf("fixture is wrong: on-disk config holds no sealed credential, "+
|
||||
"so this test cannot detect ciphertext leaking into consumers:\n%s", raw)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// writeSealedInstallNoSeed is writeSealedInstall without gateway_keys, so no
|
||||
// key is ever seeded and no startup Save() can unseal the config as a side
|
||||
// effect. That isolation is what makes the ciphertext leak observable.
|
||||
func writeSealedInstallNoSeed(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
cfg := &config.Config{
|
||||
Path: path,
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "AUTO",
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
|
||||
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}},
|
||||
}},
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("initial install: %v", err)
|
||||
}
|
||||
c.Close()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "enc:v1:") {
|
||||
t.Fatalf("fixture is wrong: no sealed credential on disk:\n%s", raw)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// countKeysWithSecret loads the config, unseals it, and counts keys whose
|
||||
// plaintext equals one of the gateway_keys entries.
|
||||
func countKeysWithSecret(t *testing.T, path string) (total, matching int) {
|
||||
t.Helper()
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box, err := config.NewSecretBox(cfg.RuntimeFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cfg.UnsealSecrets(box); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, g := range cfg.GatewayKeys {
|
||||
want[g] = true
|
||||
}
|
||||
for _, k := range cfg.Keys {
|
||||
total++
|
||||
if want[k.Key] {
|
||||
matching++
|
||||
}
|
||||
}
|
||||
return total, matching
|
||||
}
|
||||
|
||||
// Restarting must not grow the key list. Before the fix this went 2 -> 3 -> 4.
|
||||
func TestRestartDoesNotDuplicateSeededKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeSealedInstall(t, dir)
|
||||
|
||||
if _, n := countKeysWithSecret(t, path); n != 1 {
|
||||
t.Fatalf("after install: %d keys carry the seeded secret, want 1", n)
|
||||
}
|
||||
|
||||
for i := 2; i <= 4; i++ {
|
||||
c, err := New(path) // production path: reads the file
|
||||
if err != nil {
|
||||
t.Fatalf("restart %d: %v", i, err)
|
||||
}
|
||||
c.Close()
|
||||
total, n := countKeysWithSecret(t, path)
|
||||
if n != 1 {
|
||||
t.Fatalf("restart %d: %d keys carry the seeded secret (total %d), want "+
|
||||
"exactly 1 — seedKeys is comparing ciphertext against plaintext and "+
|
||||
"appending a duplicate on every start", i, n, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every provider must hold the real upstream credential, not its ciphertext.
|
||||
//
|
||||
// This deliberately runs with gateway_keys EMPTY. The buggy order was masked
|
||||
// whenever seedKeys had work to do, because its Save() unsealed memory as a
|
||||
// side effect — providers then happened to see plaintext. With no key to seed,
|
||||
// no Save runs, and the ciphertext reaches the registry directly. An earlier
|
||||
// version of this test kept gateway_keys populated and was insensitive: it
|
||||
// passed even with the bug present.
|
||||
func TestProvidersGetPlaintextCredentials(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeSealedInstallNoSeed(t, dir)
|
||||
|
||||
c, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatalf("restart: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
for _, p := range c.registry.Providers() {
|
||||
key := p.Config().APIKey
|
||||
if strings.HasPrefix(key, "enc:v1:") {
|
||||
t.Errorf("provider %q holds CIPHERTEXT api_key %q — every upstream call "+
|
||||
"would 401", p.Name(), key)
|
||||
}
|
||||
if key != "sk-upstream-secret" {
|
||||
t.Errorf("provider %q api_key = %q, want the plaintext upstream secret",
|
||||
p.Name(), key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
[
|
||||
{
|
||||
"u": "12345",
|
||||
"devid": "707304999019651",
|
||||
"market": "5daa819c-8425-4bae-88b7-2bbfec3c25ba",
|
||||
"sess": "e5b8dfc5830ce0bdb141f7177effb90a7cf8227bb23b930a427a8550bf7357df"
|
||||
},
|
||||
{
|
||||
"u": "UID-abc",
|
||||
"devid": "579362202930608",
|
||||
"market": "a5388fc1-5969-4666-bc74-be9a11f69567",
|
||||
"sess": "a47da2e13fe16d49dc859fbed58d1d99e0faef5d116acafc64a2d26013d17a1f"
|
||||
},
|
||||
{
|
||||
"u": "900000001",
|
||||
"devid": "764948639142479",
|
||||
"market": "e449daa5-b0c0-4f35-918e-8bb01b33f179",
|
||||
"sess": "fdd37c37d1cc950439c25467e6dca75fcc84a2b0d294aa910387a9a965645650"
|
||||
},
|
||||
{
|
||||
"u": "x",
|
||||
"devid": "268405722745284",
|
||||
"market": "4f2427c5-0583-4f82-8e18-4e38566dd857",
|
||||
"sess": "79be40fff0fbaee572d1ba3ab13a47af9d2f4d943115ef512c56e42132e23a66"
|
||||
},
|
||||
{
|
||||
"u": "",
|
||||
"devid": "170789623621479",
|
||||
"market": "b32897c1-f9c0-4ad2-b2b4-725455fffb6b",
|
||||
"sess": "4a61a6b4c4bcbf1040bf0edf3df2b3e7d76a956f3ef81ef8f148eadcc03d25cc"
|
||||
},
|
||||
{
|
||||
"u": "用户1",
|
||||
"devid": "263026789643405",
|
||||
"market": "1c1b4e1f-9d7a-4698-9f34-b057d9a74b0b",
|
||||
"sess": "a13dbde68977eee49818e2e2fb7d3bbb1ffb4c807a6e6f1831168a84adea0dfe"
|
||||
},
|
||||
{
|
||||
"u": "1234567890123456789012345",
|
||||
"devid": "358669731114854",
|
||||
"market": "449882ec-9a69-4a7a-b9ab-25aa59b28c50",
|
||||
"sess": "305eb96fe14a194386c0fd4148d4919870ed803b69cf75296b53ea1f41e4c574"
|
||||
}
|
||||
]
|
||||
@ -3,7 +3,6 @@ package gateway
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@ -11,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
type adapterPayload struct {
|
||||
@ -79,177 +78,6 @@ type sourcePayload struct {
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
RPM int `json:"rpm"` // optional requests-per-minute cap, 0 = unlimited
|
||||
// ModelIDs is an additive-only shorthand: when set, the named models are
|
||||
// merged into an existing source instead of Models replacing the list.
|
||||
// It exists because "add one model" is the most common scripted edit and a
|
||||
// full Models list cannot be written without reading the source first.
|
||||
ModelIDs []string `json:"model_ids,omitempty"`
|
||||
|
||||
// The four fields below are POINTERS, and that is the whole point.
|
||||
//
|
||||
// POST/PUT is an upsert that rewrites the whole source, so any field the
|
||||
// payload cannot express is silently reset to its zero value. That already
|
||||
// destroyed api_key once (fixed with resolveAPIKey) and would equally
|
||||
// destroy proxy_url, api_key_env, timeout and queue_timeout.
|
||||
//
|
||||
// A plain string/duration cannot distinguish "the caller did not mention
|
||||
// this field" from "the caller asked for the empty value", and only the
|
||||
// former may inherit. So:
|
||||
//
|
||||
// nil → field absent from the request: keep the current value
|
||||
// &"x" → present: store exactly "x" (including "" to clear it)
|
||||
//
|
||||
// api_key deliberately keeps its older "empty means inherit" rule rather
|
||||
// than gaining a pointer: that rule is already published (v1.7.6) and
|
||||
// scripts depend on it. Changing it now would let a script that echoes an
|
||||
// empty api_key erase a live credential, which is the exact failure this
|
||||
// whole area exists to prevent.
|
||||
ProxyURL *string `json:"proxy_url,omitempty"`
|
||||
APIKeyEnv *string `json:"api_key_env,omitempty"`
|
||||
Timeout *string `json:"timeout,omitempty"` // duration string, e.g. "300s"
|
||||
QueueTimeout *string `json:"queue_timeout,omitempty"` // duration string, e.g. "90s"
|
||||
}
|
||||
|
||||
// optionalSourceFields carries the Source fields a partial payload must not
|
||||
// reset, each as a pointer so "absent" and "explicitly empty" stay distinct.
|
||||
//
|
||||
// nil → absent from the request: keep the current value
|
||||
// &"" → present: clear it
|
||||
// &"x" → present: store "x"
|
||||
//
|
||||
// The alternative — a plain value with "empty means inherit" — cannot express
|
||||
// "clear this field", so emptying the proxy input in the UI would silently keep
|
||||
// the old proxy. It is exactly why api_key is NOT modelled this way: for a
|
||||
// credential, failing to keep the old value is worse than failing to clear it,
|
||||
// and that rule is already published (v1.7.6). Two fields with opposite failure
|
||||
// modes therefore get opposite rules, and both are spelled out here.
|
||||
type optionalSourceFields struct {
|
||||
ProxyURL *string
|
||||
APIKeyEnv *string
|
||||
Timeout *string
|
||||
QueueTimeout *string
|
||||
}
|
||||
|
||||
// resolve overlays the four optional fields onto payload.
|
||||
//
|
||||
// A nil pointer means the request did not mention the field, so it keeps the
|
||||
// value already stored in cur (nil cur = a source being created, where the
|
||||
// payload's own zero value is correct). A non-nil pointer always wins, including
|
||||
// when it points at the empty string, which is how a UI form clears a field.
|
||||
//
|
||||
// Only these four fields are overlaid. Everything else in payload is taken as
|
||||
// sent: an upsert that inherited the whole record would make it impossible to
|
||||
// change anything.
|
||||
func (o optionalSourceFields) resolve(payload config.Source, cur *config.Source) (config.Source, error) {
|
||||
// cur is dereferenced exactly once here so the create path (nil) cannot
|
||||
// panic on the per-field lookups below.
|
||||
var storedProxyURL, storedKeyEnv string
|
||||
var storedTimeout, storedQueueTimeout time.Duration
|
||||
if cur != nil {
|
||||
storedProxyURL, storedKeyEnv = cur.ProxyURL, cur.APIKeyEnv
|
||||
storedTimeout, storedQueueTimeout = cur.Timeout, cur.QueueTimeout
|
||||
}
|
||||
pick := func(p *string, stored string) string {
|
||||
if p != nil {
|
||||
return *p
|
||||
}
|
||||
return stored
|
||||
}
|
||||
payload.ProxyURL = pick(o.ProxyURL, storedProxyURL)
|
||||
payload.APIKeyEnv = pick(o.APIKeyEnv, storedKeyEnv)
|
||||
if err := overlayDuration(&payload.Timeout, o.Timeout, "timeout", storedTimeout); err != nil {
|
||||
return payload, err
|
||||
}
|
||||
if err := overlayDuration(&payload.QueueTimeout, o.QueueTimeout, "queue_timeout", storedQueueTimeout); err != nil {
|
||||
return payload, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// overlayDuration applies the absent/present rule to one duration field: a nil
|
||||
// pointer keeps stored, a set pointer replaces it (with "" / "0s" clearing).
|
||||
func overlayDuration(dst *time.Duration, p *string, field string, stored time.Duration) error {
|
||||
if p == nil {
|
||||
*dst = stored
|
||||
return nil
|
||||
}
|
||||
d, err := parseOptionalDuration(*p, field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dst = d
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseOptionalDuration parses a duration written as a Go duration string.
|
||||
// An empty string clears the field back to the config default (0), which is
|
||||
// what a UI form submitting an empty timeout box should mean. Parsing happens
|
||||
// only for fields the request actually mentions, so a typo can never surface
|
||||
// as a silent reset of something else.
|
||||
func parseOptionalDuration(v, field string) (time.Duration, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s %q is not a duration (use e.g. 120s, 5m, 1h)", field, v)
|
||||
}
|
||||
if d < 0 {
|
||||
return 0, fmt.Errorf("%s must be >= 0 (got %s)", field, v)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// keepExistingAPIKey is the mask a client sends when it means "keep the
|
||||
// current credential". POST /api/sources is an upsert, so a script that edits
|
||||
// one field and echoes a placeholder back would otherwise persist that
|
||||
// placeholder as the live api_key — which fails closed as 401 on the next
|
||||
// request, long after the script exited successfully. See TestSourceUpsertKeepsAPIKey.
|
||||
const keepExistingAPIKey = "__KEEP__"
|
||||
|
||||
// resolveAPIKey returns the credential to store, inheriting the current one
|
||||
// when the payload does not supply a new one.
|
||||
//
|
||||
// Two distinct "no new credential" signals exist and they must not collapse:
|
||||
// - the explicit mask keepExistingAPIKey — always inherit.
|
||||
// - the empty string — inherit only for a source that already has a
|
||||
// credential. Empty is a legitimate value for a *new* credential-less
|
||||
// source (some local runtimes use a no-auth sentinel, local servers ""),
|
||||
// so a fresh source with an empty key must stay empty rather than inherit
|
||||
// nothing and fail differently.
|
||||
func (g *Gateway) resolveAPIKey(name, want string, exists bool) string {
|
||||
if want == keepExistingAPIKey {
|
||||
return g.currentAPIKey(name)
|
||||
}
|
||||
if exists && want == "" {
|
||||
return g.currentAPIKey(name)
|
||||
}
|
||||
return want
|
||||
}
|
||||
|
||||
func (g *Gateway) currentAPIKey(name string) string {
|
||||
if s := g.sourceByName(name); s != nil {
|
||||
return s.APIKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sourceByName returns a live view of one source, or nil when it does not
|
||||
// exist. It goes through core.Sources() so the key is already unsealed and
|
||||
// defaults applied, matching what the routes actually use.
|
||||
func (g *Gateway) sourceByName(name string) *config.Source {
|
||||
for _, s := range g.core.Sources() {
|
||||
if s.Name == name {
|
||||
ss := s
|
||||
return &ss
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) sourceExists(name string) bool {
|
||||
return g.sourceByName(name) != nil
|
||||
}
|
||||
|
||||
func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
@ -263,34 +91,17 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.core.Sources()})
|
||||
case http.MethodPost, http.MethodPut:
|
||||
case http.MethodPost:
|
||||
var p sourcePayload
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
// PUT addresses the source by path; the body name is only a fallback so
|
||||
// a single-field edit does not have to echo the name back. A body that
|
||||
// disagrees with the path is a caller bug, not something to silently
|
||||
// resolve — it usually means the wrong source is about to be written.
|
||||
if r.Method == http.MethodPut {
|
||||
if path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "source name required in path")
|
||||
return
|
||||
}
|
||||
if p.Name != "" && p.Name != path {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||||
"source name in body ("+p.Name+") does not match path ("+path+")")
|
||||
return
|
||||
}
|
||||
p.Name = path
|
||||
}
|
||||
exists := g.sourceExists(p.Name)
|
||||
src := config.Source{
|
||||
Name: p.Name,
|
||||
BaseURL: p.BaseURL,
|
||||
APIKey: g.resolveAPIKey(p.Name, p.APIKey, exists),
|
||||
APIKey: p.APIKey,
|
||||
Adapter: p.Adapter,
|
||||
Endpoint: p.Endpoint,
|
||||
ImageEndpoint: p.ImageEndpoint,
|
||||
@ -302,44 +113,6 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
MaxConcurrent: p.MaxConcurrent,
|
||||
RPM: p.RPM,
|
||||
}
|
||||
// The four upsert-fragile fields are applied last: a nil pointer means
|
||||
// "not in the request", so they are inherited from the stored source
|
||||
// instead of being reset to the payload's zero value. This runs whether
|
||||
// or not the request mentioned them, because resolve() itself decides
|
||||
// per field — passing them unconditionally keeps the rule in one place.
|
||||
src, err := optionalSourceFields{
|
||||
ProxyURL: p.ProxyURL,
|
||||
APIKeyEnv: p.APIKeyEnv,
|
||||
Timeout: p.Timeout,
|
||||
QueueTimeout: p.QueueTimeout,
|
||||
}.resolve(src, g.sourceByName(p.Name))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
||||
return
|
||||
}
|
||||
// model_ids is additive: "add these models" is the common scripted edit
|
||||
// and it must not require reading (and echoing) the whole list back.
|
||||
// A request that omits models entirely is therefore a pure add, not a
|
||||
// request to empty the list -- that distinction is the whole point.
|
||||
if len(p.ModelIDs) > 0 {
|
||||
if len(src.Models) == 0 {
|
||||
if existing := g.sourceByName(p.Name); existing != nil {
|
||||
src.Models = append(src.Models, existing.Models...)
|
||||
}
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, m := range src.Models {
|
||||
have[m.ID] = true
|
||||
}
|
||||
for _, id := range p.ModelIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || have[id] {
|
||||
continue
|
||||
}
|
||||
have[id] = true
|
||||
src.Models = append(src.Models, config.Model{ID: id, Kind: "chat"})
|
||||
}
|
||||
}
|
||||
if err := g.core.AddSource(src); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
||||
return
|
||||
@ -433,62 +206,13 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
key := exportKey(r)
|
||||
// ?period=day|week|month|all switches the whole payload to a calendar
|
||||
// window (UTC) aggregated off the audit files, instead of the
|
||||
// since-process-start totals. The CSV exports below are unaffected: they
|
||||
// take an explicit from/to range and stream, so a period selector there
|
||||
// would only be a second way to spell the same bounds.
|
||||
if p := periodFromQuery(r.URL.Query()); p != PeriodAll {
|
||||
if !ValidPeriod(p) {
|
||||
writeError(w, http.StatusBadRequest, "bad_period",
|
||||
"period must be one of day, week, month, all")
|
||||
return
|
||||
}
|
||||
out := g.stats.PeriodSnapshot(p, key, time.Now())
|
||||
// The dashboard paints its records table from this payload's `records`
|
||||
// field (paintRecords(st.records)), so a period view that omits the key
|
||||
// renders an empty table no matter how much traffic the window had. The
|
||||
// aggregate above is period-scoped; the record list is the same
|
||||
// newest-first page the scroll handler pulls from /api/stats/records,
|
||||
// which is deliberately not period-filtered (the cursor, not a date
|
||||
// range, is its state) — that asymmetry is what the paging code
|
||||
// already assumes.
|
||||
page := g.stats.AuditPage("", limit, key)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"period": out.Period,
|
||||
"from": out.From,
|
||||
"total": out.Total,
|
||||
"by_key": out.ByKey,
|
||||
"by_model": out.Models,
|
||||
"by_source": out.Srcs,
|
||||
"by_status": out.Status,
|
||||
"buckets": out.Bucket,
|
||||
"truncated": out.Truncated,
|
||||
// "Active requests" is a live gauge (requests in flight right now),
|
||||
// not a period aggregate. Omitting it from the period response made
|
||||
// the KPI read `st.active || 0` → 0 on every day/week/month view while
|
||||
// the all-time view showed the truth, which read as "the gateway is
|
||||
// idle" precisely when it was busiest. A calendar window cannot
|
||||
// filter it, so it travels with every response unfiltered.
|
||||
"active": g.stats.Active(),
|
||||
"records": page.Records,
|
||||
// The paging cursor has to travel with the first page, otherwise
|
||||
// the scroll handler has nothing to page from and the table is
|
||||
// stuck at one screen forever.
|
||||
"next_cursor": page.Next,
|
||||
"has_more": page.HasMore,
|
||||
"rotated": page.Rotated,
|
||||
"key_names": g.keyNamesFor(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("export") == "csv" {
|
||||
from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
|
||||
to, _ := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64)
|
||||
if to == 0 {
|
||||
to = time.Now().UnixMilli()
|
||||
}
|
||||
csvHeaders(w, "modelrouter-requests.csv")
|
||||
csvHeaders(w, "llmsproxy-requests.csv")
|
||||
cw := csv.NewWriter(w)
|
||||
names := map[string]string{}
|
||||
for _, k := range g.core.ListKeys() {
|
||||
@ -537,7 +261,7 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("export") == "keys-csv" {
|
||||
csvHeaders(w, "modelrouter-keys.csv")
|
||||
csvHeaders(w, "llmsproxy-keys.csv")
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write([]string{"key", "key_name", "role", "models", "total_requests", "success_requests", "failed_requests", "prompt_tokens", "completion_tokens", "total_tokens", "avg_latency_ms", "max_latency_ms", "created_at"})
|
||||
keyFilter := exportKey(r)
|
||||
@ -589,37 +313,12 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
snap := g.stats.Snapshot(limit, key)
|
||||
snap["key_names"] = g.keyNamesFor()
|
||||
// Records come from the audit files via the same AuditPage the scroll
|
||||
// handler uses, not from Snapshot's in-memory ring. Two reasons:
|
||||
//
|
||||
// - Ordering. The ring is oldest-first, AuditPage is newest-first. The
|
||||
// first screen and the paged continuation have to agree, or scrolling
|
||||
// splices new rows in above old ones.
|
||||
// - Depth. The ring holds maxRecs entries (a few hundred); the audit
|
||||
// files hold everything, so "all" actually means all and the cursor
|
||||
// can keep paging.
|
||||
//
|
||||
// It also carries next_cursor, without which the table can never load a
|
||||
// second page.
|
||||
page := g.stats.AuditPage("", limit, key)
|
||||
snap["records"] = page.Records
|
||||
snap["next_cursor"] = page.Next
|
||||
snap["has_more"] = page.HasMore
|
||||
snap["rotated"] = page.Rotated
|
||||
writeJSON(w, http.StatusOK, snap)
|
||||
}
|
||||
|
||||
// keyNamesFor is the masked-id -> display-name map every stats payload needs.
|
||||
// It is keyed by keyID (the mask), not the raw key, because that is what the
|
||||
// aggregate rows carry — building it in one place stops the period branch and
|
||||
// the lifetime branch from drifting apart.
|
||||
func (g *Gateway) keyNamesFor() map[string]string {
|
||||
names := map[string]string{}
|
||||
keyNames := map[string]string{}
|
||||
for _, k := range g.core.ListKeys() {
|
||||
names[keyID(k.Key)] = k.Name
|
||||
keyNames[keyID(k.Key)] = k.Name
|
||||
}
|
||||
return names
|
||||
snap["key_names"] = keyNames
|
||||
writeJSON(w, http.StatusOK, snap)
|
||||
}
|
||||
|
||||
// handleStatsRecordsAPI pages the request records straight off the audit files.
|
||||
|
||||
@ -1,474 +0,0 @@
|
||||
package gateway
|
||||
|
||||
// Agent-facing management API.
|
||||
//
|
||||
// The Web UI has always driven the gateway through /api/*, so the management
|
||||
// surface already exists. What it lacked was anything an agent could *rely* on:
|
||||
// no way to discover the contract, no single call that answers "what is the
|
||||
// current state", and per-endpoint response shapes that make scripting brittle.
|
||||
//
|
||||
// This file adds a versioned, self-describing facade under /api/v1 without
|
||||
// touching the existing endpoints, so the Web UI keeps working unchanged while
|
||||
// agents get a stable contract:
|
||||
//
|
||||
// GET /api/v1 — machine-readable index of every endpoint
|
||||
// GET /api/v1/overview — one call: sources + auto chain + keys + health
|
||||
// GET /api/v1/sources — sources, credentials masked
|
||||
// GET /api/v1/sources/{name} — one source
|
||||
// GET /api/v1/auto — scheduling chain + live slot states
|
||||
// GET /api/v1/keys — key metadata (never the secret)
|
||||
// GET /api/v1/models — every model id the gateway can route
|
||||
// GET /api/v1/health — per-source health, no auth needed beyond the key
|
||||
//
|
||||
// Auth is the same gateway key as everywhere else (Authorization: Bearer, or
|
||||
// ?api_key=, or the gw_key cookie). Read endpoints accept any role; writes
|
||||
// still require admin, enforced by the same middleware the UI goes through.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// apiV1Routes dispatches /api/v1/*. Kept separate from routes() so the original
|
||||
// switch stays readable and the versioned surface can grow on its own.
|
||||
func (g *Gateway) apiV1Routes(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
|
||||
path = strings.Trim(path, "/")
|
||||
|
||||
switch {
|
||||
case path == "":
|
||||
g.apiV1Index(w, r)
|
||||
case path == "overview":
|
||||
g.apiV1Overview(w, r)
|
||||
case path == "health":
|
||||
g.apiV1Health(w, r)
|
||||
case path == "models":
|
||||
g.apiV1Models(w, r)
|
||||
case path == "sources":
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"sources": maskSources(g.core.Sources())})
|
||||
case strings.HasPrefix(path, "sources/"):
|
||||
name, err := decodePathSegment(strings.TrimPrefix(path, "sources/"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
||||
"use PUT/DELETE on /api/sources/{name} to modify a source")
|
||||
return
|
||||
}
|
||||
for _, s := range g.core.Sources() {
|
||||
if s.Name == name {
|
||||
// ?reveal=credentials is admin-only and is what the Web UI's
|
||||
// edit dialog uses: a form that round-trips a source must be
|
||||
// able to show the current key, otherwise saving an unrelated
|
||||
// field would blank it. Everything else stays masked.
|
||||
if r.URL.Query().Get("reveal") == "credentials" {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden",
|
||||
"admin role required to reveal credentials")
|
||||
return
|
||||
}
|
||||
// The edit form round-trips this response, so it needs the credential in the
|
||||
// clear AND the durations — see maskSourceFields.
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSourceFields(s, true, true)})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSource(s)})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "no such source: "+name)
|
||||
case path == "auto":
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"rules": g.core.AutoRules(),
|
||||
"image_rules": g.core.AutoImageRules(),
|
||||
"states": g.core.AutoSlotStates(),
|
||||
})
|
||||
case path == "keys":
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
||||
"use POST/PUT/DELETE on /api/keys to manage keys")
|
||||
return
|
||||
}
|
||||
keys := g.core.ListKeys()
|
||||
out := make([]map[string]interface{}, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": k.Name,
|
||||
"role": k.Role,
|
||||
"models": k.Models,
|
||||
"note": k.Note,
|
||||
"created_at": k.CreatedAt,
|
||||
"seed": k.Seed,
|
||||
// Quotas live on the scope entries (k.Models), echoed above;
|
||||
// there is deliberately no key-wide total.
|
||||
// The secret itself is never echoed. An operator that needs it
|
||||
// already has it from creation time or from config.yaml.
|
||||
"key_prefix": maskKey(k.Key),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"keys": out})
|
||||
default:
|
||||
g.apiV1Index(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// apiV1Index is the discovery document: it names every endpoint with its
|
||||
// method, auth requirement and purpose, so an agent does not have to scrape the
|
||||
// HTML to find out what it can call.
|
||||
func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
|
||||
type ep struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Auth string `json:"auth"`
|
||||
Summary string `json:"summary"`
|
||||
WriteEffect string `json:"writes,omitempty"`
|
||||
}
|
||||
index := map[string]interface{}{
|
||||
"api_version": "v1",
|
||||
"description": "modelrouter management API. Authenticate with a gateway key: " +
|
||||
"'Authorization: Bearer <key>', '?api_key=<key>', or the gw_key cookie. " +
|
||||
"Read endpoints accept any role; mutations require an admin key.",
|
||||
"discovery": []ep{
|
||||
{Method: "GET", Path: "/api/v1", Auth: "any", Summary: "this document"},
|
||||
{Method: "GET", Path: "/api/v1/overview", Auth: "any",
|
||||
Summary: "current state in one call: sources, auto chain, key count, source health"},
|
||||
{Method: "GET", Path: "/api/v1/health", Auth: "any",
|
||||
Summary: "per-source health snapshot only"},
|
||||
{Method: "GET", Path: "/api/v1/models", Auth: "any",
|
||||
Summary: "every model id the gateway can route, grouped by owning source"},
|
||||
{Method: "GET", Path: "/api/v1/sources", Auth: "any",
|
||||
Summary: "all sources with credentials masked"},
|
||||
{Method: "GET", Path: "/api/v1/sources/{name}", Auth: "any", Summary: "one source"},
|
||||
{Method: "GET", Path: "/api/v1/auto", Auth: "any",
|
||||
Summary: "AUTO scheduling chain and live per-slot state"},
|
||||
{Method: "GET", Path: "/api/v1/keys", Auth: "admin",
|
||||
Summary: "gateway key metadata; secrets are never returned"},
|
||||
|
||||
{Method: "GET", Path: "/api/sources", Auth: "admin", Summary: "raw source list (includes api_key)"},
|
||||
{Method: "POST", Path: "/api/sources", Auth: "admin", Summary: "add or replace a source",
|
||||
WriteEffect: "writes config.yaml (api_key sealed at rest)"},
|
||||
{Method: "PUT", Path: "/api/sources/{name}", Auth: "admin", Summary: "update one source",
|
||||
WriteEffect: "writes config.yaml (api_key sealed at rest)"},
|
||||
{Method: "DELETE", Path: "/api/sources/{name}", Auth: "admin", Summary: "delete a source",
|
||||
WriteEffect: "writes config.yaml"},
|
||||
|
||||
{Method: "GET", Path: "/api/auto", Auth: "any", Summary: "scheduling chain (readable by any role)"},
|
||||
{Method: "PUT", Path: "/api/auto", Auth: "admin", Summary: "replace the scheduling chain",
|
||||
WriteEffect: "writes config.yaml (the AUTO chain lives here)"},
|
||||
|
||||
{Method: "GET", Path: "/api/keys", Auth: "admin", Summary: "gateway keys"},
|
||||
{Method: "POST", Path: "/api/keys", Auth: "admin", Summary: "create a gateway key",
|
||||
WriteEffect: "writes config.yaml"},
|
||||
{Method: "PUT", Path: "/api/keys/{key}", Auth: "admin",
|
||||
Summary: "update a gateway key (name, role, model scopes and their per-model quotas)",
|
||||
WriteEffect: "writes config.yaml"},
|
||||
{Method: "DELETE", Path: "/api/keys/{key}", Auth: "admin",
|
||||
Summary: "delete a gateway key — the path segment is the KEY itself, not its name",
|
||||
WriteEffect: "writes config.yaml"},
|
||||
|
||||
{Method: "GET", Path: "/api/status", Auth: "any", Summary: "per-source health detail"},
|
||||
{Method: "GET", Path: "/api/stats", Auth: "any", Summary: "usage aggregates"},
|
||||
{Method: "GET", Path: "/api/stats/records", Auth: "any", Summary: "paged request records"},
|
||||
{Method: "GET", Path: "/api/adapters", Auth: "admin", Summary: "installed Lua adapters"},
|
||||
{Method: "POST", Path: "/api/sources/discover", Auth: "admin",
|
||||
Summary: "probe a base_url+api_key for its model catalog",
|
||||
// Probing is read-only with respect to configuration: it writes
|
||||
// nothing, which is why it is safe to call repeatedly while the
|
||||
// operator is still typing a key.
|
||||
WriteEffect: "none",
|
||||
},
|
||||
{Method: "POST", Path: "/api/sources/discover/apply", Auth: "admin",
|
||||
Summary: "probe and write the source in one step",
|
||||
// Requires replace_models=true when the source already exists,
|
||||
// so re-running discovery cannot silently discard a hand-tuned
|
||||
// model list. An empty api_key keeps the stored credential.
|
||||
WriteEffect: "creates or replaces a source and its model list",
|
||||
},
|
||||
{Method: "GET", Path: "/api/source_templates", Auth: "admin", Summary: "source templates"},
|
||||
{Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"},
|
||||
},
|
||||
"conventions": map[string]interface{}{
|
||||
"errors": "{ \"error\": { \"type\": <code>, \"message\": <text> } }",
|
||||
"path_escape": "URL-encode source and key names; {name} is a single path segment",
|
||||
"idempotency": "POST /api/sources and PUT /api/sources/{name} both upsert by name; " +
|
||||
"PUT takes the name from the path and rejects a body name that disagrees",
|
||||
"partial_update": "api_key may be omitted or sent as the literal \"__KEEP__\" to inherit the " +
|
||||
"current credential; model_ids adds models to the existing list instead of replacing it, " +
|
||||
"so a one-field edit never needs to read the source first",
|
||||
"optional_fields": "proxy_url, api_key_env, timeout and queue_timeout use presence semantics: " +
|
||||
"OMITTED from the request keeps the stored value, present (even as \"\") overwrites it. " +
|
||||
"The upsert rewrites the whole source, so without this a one-field edit would silently reset " +
|
||||
"them — and api_key_env in particular is only visible as a credential failure on the NEXT " +
|
||||
"upstream call. api_key deliberately keeps the older \"empty means inherit\" rule instead, " +
|
||||
"because losing a credential breaks the source while losing a proxy only changes its route.",
|
||||
"durations": "timeout and queue_timeout are Go duration strings (\"300s\", \"5m\", \"1h\"); " +
|
||||
"the empty string clears them back to the config defaults",
|
||||
"config_truth": "all configuration lives in config.yaml; API writes are persisted immediately",
|
||||
"credentials": "credentials are masked by default. GET /api/v1/sources/{name}?reveal=credentials " +
|
||||
"returns them in the clear and is admin-only — the Web UI edit dialog uses it, because a form " +
|
||||
"that round-trips a source must show the current key or saving another field would blank it.",
|
||||
},
|
||||
}
|
||||
writeJSON(w, http.StatusOK, index)
|
||||
}
|
||||
|
||||
// apiV1Overview is the "what is the current state" call an agent makes first.
|
||||
// One round trip instead of five.
|
||||
func (g *Gateway) apiV1Overview(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
sources := g.core.Sources()
|
||||
names := make([]string, 0, len(sources))
|
||||
modelCount := 0
|
||||
for _, s := range sources {
|
||||
names = append(names, s.Name)
|
||||
modelCount += len(s.Models)
|
||||
}
|
||||
keys := g.core.ListKeys()
|
||||
adminCount, userCount := 0, 0
|
||||
for _, k := range keys {
|
||||
if k.Role == "admin" {
|
||||
adminCount++
|
||||
} else {
|
||||
userCount++
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"api_version": "v1",
|
||||
"summary": map[string]interface{}{
|
||||
"source_count": len(sources),
|
||||
"model_count": modelCount,
|
||||
"gateway_key_count": len(keys),
|
||||
"admin_key_count": adminCount,
|
||||
"user_key_count": userCount,
|
||||
"auto_slots": len(g.core.AutoRules()),
|
||||
},
|
||||
"sources": maskSources(sources),
|
||||
"auto": g.core.AutoRules(),
|
||||
"auto_image": g.core.AutoImageRules(),
|
||||
"health": g.sourceHealthBrief(),
|
||||
"caller": map[string]interface{}{
|
||||
"role": reqRole(r.Context()),
|
||||
"key": maskKey(reqKey(r.Context())),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Gateway) apiV1Health(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.sourceHealthBrief()})
|
||||
}
|
||||
|
||||
// apiV1Models lists routable model ids per source, which is what an agent needs
|
||||
// to build a valid request — /v1/models flattens them and hides the owner.
|
||||
func (g *Gateway) apiV1Models(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
bySource := map[string][]string{}
|
||||
var all []string
|
||||
for _, s := range g.core.Sources() {
|
||||
for _, m := range s.Models {
|
||||
bySource[s.Name] = append(bySource[s.Name], m.ID)
|
||||
all = append(all, m.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(all)
|
||||
all = dedupeStrings(all)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"count": len(all),
|
||||
"models": all,
|
||||
"by_source": bySource,
|
||||
"note": "request a model as \"<source>:<model>\" to pin one source, or the bare id to let the gateway choose",
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// maskSources returns sources with credentials replaced by a presence marker.
|
||||
// The UI still uses /api/sources (which returns real values, because its edit
|
||||
// form round-trips them); this is the safe view for programmatic callers.
|
||||
func maskSources(srcs []config.Source) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(srcs))
|
||||
for _, s := range srcs {
|
||||
out = append(out, maskSource(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func maskSource(s config.Source) map[string]interface{} {
|
||||
return maskSourceFields(s, false, false)
|
||||
}
|
||||
|
||||
// maskSourceFields builds the API view of a source.
|
||||
//
|
||||
// revealCredentials swaps the masked api_key for the real one — admin-only, and
|
||||
// used by the WebUI edit dialog, whose form has to round-trip the whole source
|
||||
// or saving an unrelated field would blank the key.
|
||||
//
|
||||
// exposeDurations adds the two durations as strings, matching what the write
|
||||
// path accepts. It rides along with the credential reveal because both exist for
|
||||
// the same reason: config.Source tags Timeout/QueueTimeout `json:"-"`, so a
|
||||
// plain marshal of the struct omits them. A form that cannot SEE the stored
|
||||
// timeout would clear it on every save, since the timeout box is always sent.
|
||||
func maskSourceFields(s config.Source, revealCredentials, exposeDurations bool) map[string]interface{} {
|
||||
key := maskKey(s.APIKey)
|
||||
if revealCredentials {
|
||||
key = s.APIKey
|
||||
}
|
||||
m := map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"base_url": s.BaseURL,
|
||||
"adapter": s.Adapter,
|
||||
"endpoint": s.Endpoint,
|
||||
"image_endpoint": s.ImageEndpoint,
|
||||
"api_key": key,
|
||||
"api_key_set": s.APIKey != "",
|
||||
"models": s.Models,
|
||||
"headers": maskHeaders(s.Headers),
|
||||
"proxy_url": s.ProxyURL,
|
||||
"api_key_env": s.APIKeyEnv,
|
||||
"meta": s.Meta,
|
||||
"temperature": s.Temperature,
|
||||
"max_tokens": s.MaxTokens,
|
||||
"max_concurrent": s.MaxConcurrent,
|
||||
"rpm": s.RPM,
|
||||
}
|
||||
if exposeDurations {
|
||||
m["timeout"] = s.Timeout.String()
|
||||
m["queue_timeout"] = s.QueueTimeout.String()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func maskKey(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(k, "enc:v1:") {
|
||||
return "(sealed)"
|
||||
}
|
||||
if len(k) <= 10 {
|
||||
return k[:2] + "…"
|
||||
}
|
||||
return k[:6] + "…" + k[len(k)-4:]
|
||||
}
|
||||
|
||||
func maskHeaders(h map[string]string) map[string]string {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(h))
|
||||
for k, v := range h {
|
||||
out[k] = maskKey(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sourceHealthBrief reuses the same registry status the UI's /api/status shows,
|
||||
// so the agent view and the UI view cannot drift apart. Recent-traffic counters
|
||||
// come from the same stats window, because a source that is actually serving
|
||||
// traffic must never look down just because a probe was rate-limited.
|
||||
func (g *Gateway) sourceHealthBrief() []map[string]interface{} {
|
||||
sts := g.core.Registry().Status()
|
||||
recent := g.stats.SourceRecent(300)
|
||||
avgs := g.stats.SourceAverages(300)
|
||||
for i := range sts {
|
||||
if v, ok := recent[sts[i].Name]; ok {
|
||||
sts[i].RecentOK = v[0]
|
||||
sts[i].RecentErr = v[1]
|
||||
}
|
||||
if a, ok := avgs[sts[i].Name]; ok {
|
||||
sts[i].AvgFirstByteMs = a.AvgFirstByteMs
|
||||
sts[i].AvgTokPerS = a.AvgTokPerS
|
||||
}
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(sts))
|
||||
for _, s := range sts {
|
||||
row := map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"adapter": s.Adapter,
|
||||
"healthy": s.Healthy,
|
||||
"available": s.Available,
|
||||
"live_available": s.LiveAvailable,
|
||||
"model_count": len(s.Models),
|
||||
}
|
||||
if s.LastError != "" {
|
||||
row["last_error"] = s.LastError
|
||||
}
|
||||
if s.LastChecked > 0 {
|
||||
row["last_checked"] = s.LastChecked
|
||||
}
|
||||
if s.RecentOK > 0 || s.RecentErr > 0 {
|
||||
row["recent_ok"] = s.RecentOK
|
||||
row["recent_err"] = s.RecentErr
|
||||
}
|
||||
if s.AvgFirstByteMs > 0 {
|
||||
row["avg_first_byte_ms"] = s.AvgFirstByteMs
|
||||
}
|
||||
if s.AvgTokPerS > 0 {
|
||||
row["avg_tok_per_s"] = s.AvgTokPerS
|
||||
}
|
||||
if s.FailCount > 0 {
|
||||
row["fail_count"] = s.FailCount
|
||||
}
|
||||
if s.Permanent {
|
||||
row["permanent"] = true
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decodePathSegment URL-decodes one path segment and rejects an empty result,
|
||||
// so a name containing a slash cannot be silently mis-resolved.
|
||||
func decodePathSegment(seg string) (string, error) {
|
||||
s, err := url.PathUnescape(seg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s == "" {
|
||||
return "", errEmptySegment
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func dedupeStrings(in []string) []string {
|
||||
out := in[:0]
|
||||
var last string
|
||||
for i, s := range in {
|
||||
if i == 0 || s != last {
|
||||
out = append(out, s)
|
||||
}
|
||||
last = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// errEmptySegment marks a path like /api/v1/sources/ with no name after it.
|
||||
var errEmptySegment = errors.New("empty path segment")
|
||||
@ -1,299 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// v1Gateway builds a gateway with one source and two keys, and returns the
|
||||
// secrets so a test can assert they never appear in a response.
|
||||
func v1Gateway(t *testing.T) (*Gateway, string, string) {
|
||||
t.Helper()
|
||||
g := newTestGateway(t, config.Source{
|
||||
Name: "up",
|
||||
BaseURL: "http://up.example/v1",
|
||||
APIKey: "sk-up-secret",
|
||||
Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m1", Kind: "chat"}},
|
||||
})
|
||||
// The shared helper only seeds one admin key; add a user key through the
|
||||
// core so role-gated endpoints have something to reject.
|
||||
if _, err := g.core.CreateKey("agent", "user", nil, "test agent key"); err != nil {
|
||||
t.Fatalf("CreateKey: %v", err)
|
||||
}
|
||||
rec := doReq(t, g, http.MethodGet, "/api/keys", "")
|
||||
var doc struct {
|
||||
Keys []config.GWKey `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("decode keys: %v", err)
|
||||
}
|
||||
userKey := ""
|
||||
for _, k := range doc.Keys {
|
||||
if k.Role == "user" {
|
||||
userKey = k.Key
|
||||
}
|
||||
}
|
||||
if userKey == "" {
|
||||
t.Fatal("no user key was created")
|
||||
}
|
||||
return g, "sk-test", userKey
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, body string, v interface{}) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal([]byte(body), v); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV1IndexIsDiscoverable(t *testing.T) {
|
||||
g, _, _ := v1Gateway(t)
|
||||
rec := doReq(t, g, http.MethodGet, "/api/v1", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var doc struct {
|
||||
APIVersion string `json:"api_version"`
|
||||
Description string `json:"description"`
|
||||
Discovery []struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Auth string `json:"auth"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"discovery"`
|
||||
Conventions map[string]interface{} `json:"conventions"`
|
||||
}
|
||||
decodeJSON(t, rec.Body.String(), &doc)
|
||||
if doc.APIVersion != "v1" {
|
||||
t.Errorf("api_version = %q", doc.APIVersion)
|
||||
}
|
||||
if doc.Description == "" {
|
||||
t.Error("index should carry a usage description")
|
||||
}
|
||||
if len(doc.Discovery) == 0 {
|
||||
t.Fatal("discovery list is empty")
|
||||
}
|
||||
want := map[string]bool{
|
||||
"/api/v1/overview": false, "/api/v1/sources": false,
|
||||
"/api/v1/auto": false, "/api/v1/models": false,
|
||||
"/api/v1/health": false, "/api/v1/keys": false,
|
||||
"/api/sources": false, "/api/auto": false, "/v1/chat/completions": false,
|
||||
}
|
||||
for _, e := range doc.Discovery {
|
||||
if _, ok := want[e.Path]; ok {
|
||||
want[e.Path] = true
|
||||
}
|
||||
if e.Method == "" || e.Path == "" || e.Auth == "" || e.Summary == "" {
|
||||
t.Errorf("incomplete discovery entry: %+v", e)
|
||||
}
|
||||
}
|
||||
for p, found := range want {
|
||||
if !found {
|
||||
t.Errorf("discovery is missing %s", p)
|
||||
}
|
||||
}
|
||||
if doc.Conventions["errors"] == nil {
|
||||
t.Error("conventions should document the error shape")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV1OverviewSummarisesState(t *testing.T) {
|
||||
g, _, _ := v1Gateway(t)
|
||||
rec := doReq(t, g, http.MethodGet, "/api/v1/overview", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var doc struct {
|
||||
Summary struct {
|
||||
SourceCount int `json:"source_count"`
|
||||
ModelCount int `json:"model_count"`
|
||||
GatewayKeyCount int `json:"gateway_key_count"`
|
||||
} `json:"summary"`
|
||||
Sources []map[string]interface{} `json:"sources"`
|
||||
Auto []map[string]interface{} `json:"auto"`
|
||||
Health []map[string]interface{} `json:"health"`
|
||||
Caller struct {
|
||||
Role string `json:"role"`
|
||||
} `json:"caller"`
|
||||
}
|
||||
decodeJSON(t, rec.Body.String(), &doc)
|
||||
if doc.Summary.SourceCount != 1 {
|
||||
t.Errorf("source_count = %d, want 1", doc.Summary.SourceCount)
|
||||
}
|
||||
if doc.Summary.ModelCount != 1 {
|
||||
t.Errorf("model_count = %d, want 1", doc.Summary.ModelCount)
|
||||
}
|
||||
if doc.Summary.GatewayKeyCount < 2 {
|
||||
t.Errorf("gateway_key_count = %d, want >= 2", doc.Summary.GatewayKeyCount)
|
||||
}
|
||||
if len(doc.Sources) != 1 || doc.Sources[0]["name"] != "up" {
|
||||
t.Errorf("sources = %+v", doc.Sources)
|
||||
}
|
||||
if doc.Caller.Role != "admin" {
|
||||
t.Errorf("caller.role = %q, want admin", doc.Caller.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV1NeverEchoesSecrets(t *testing.T) {
|
||||
g, admin, user := v1Gateway(t)
|
||||
for _, path := range []string{"/api/v1", "/api/v1/overview", "/api/v1/sources", "/api/v1/sources/up", "/api/v1/keys", "/api/v1/models", "/api/v1/health", "/api/v1/auto"} {
|
||||
rec := doReq(t, g, http.MethodGet, path, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d: %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "sk-up-secret") {
|
||||
t.Errorf("%s leaked the source api_key", path)
|
||||
}
|
||||
if admin != "" && strings.Contains(rec.Body.String(), admin) {
|
||||
t.Errorf("%s leaked the admin gateway key", path)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), user) {
|
||||
t.Errorf("%s leaked the user gateway key", path)
|
||||
}
|
||||
}
|
||||
// Masking must still be useful: it says a key is configured.
|
||||
rec := doReq(t, g, http.MethodGet, "/api/v1/sources/up", "")
|
||||
if !strings.Contains(rec.Body.String(), `"api_key_set":true`) {
|
||||
t.Error("masked source should report that a key is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV1KeysRequiresAdminAndMasks(t *testing.T) {
|
||||
g, _, user := v1Gateway(t)
|
||||
// Authenticate as the user key by swapping the shared helper's header.
|
||||
req := newAuthedRequest(t, http.MethodGet, "/api/v1/keys", user)
|
||||
rec := serveViaHandler(t, g, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("user role on /api/v1/keys = %d, want 403: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV1SourcesNotFound(t *testing.T) {
|
||||
g, _, _ := v1Gateway(t)
|
||||
rec := doReq(t, g, http.MethodGet, "/api/v1/sources/nope", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV1RejectsWrites(t *testing.T) {
|
||||
g, _, _ := v1Gateway(t)
|
||||
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||
rec := doReq(t, g, m, "/api/v1/sources", `{}`)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("%s /api/v1/sources returned 200; the v1 read facade must not mutate", m)
|
||||
}
|
||||
}
|
||||
// And the source must be untouched.
|
||||
rec := doReq(t, g, http.MethodGet, "/api/v1/sources", "")
|
||||
if !strings.Contains(rec.Body.String(), `"name":"up"`) {
|
||||
t.Error("the source disappeared after a rejected write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV1ModelsGroupsBySource(t *testing.T) {
|
||||
g, _, _ := v1Gateway(t)
|
||||
rec := doReq(t, g, http.MethodGet, "/api/v1/models", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var doc struct {
|
||||
Count int `json:"count"`
|
||||
Models []string `json:"models"`
|
||||
BySource map[string][]string `json:"by_source"`
|
||||
}
|
||||
decodeJSON(t, rec.Body.String(), &doc)
|
||||
if doc.Count != 1 || doc.Models[0] != "m1" {
|
||||
t.Errorf("models = %+v", doc.Models)
|
||||
}
|
||||
if len(doc.BySource["up"]) != 1 || doc.BySource["up"][0] != "m1" {
|
||||
t.Errorf("by_source = %+v", doc.BySource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIV1RequiresAuthentication(t *testing.T) {
|
||||
g, _, _ := v1Gateway(t)
|
||||
for _, path := range []string{"/api/v1", "/api/v1/overview", "/api/v1/sources", "/api/v1/health", "/api/v1/models"} {
|
||||
req := newRequest(http.MethodGet, path, "")
|
||||
rec := serveViaHandler(t, g, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s without a key = %d, want 401", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskKeyHidesTheSecret(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"ab": "ab…",
|
||||
"sk-gw-abcdefghijkl": "sk-gw-…ijkl",
|
||||
"enc:v1:AAAA": "(sealed)",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := maskKey(in); got != want {
|
||||
t.Errorf("maskKey(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
// The masked form must not reveal the middle of the secret.
|
||||
full := maskKey("sk-gw-1234567890abcdef")
|
||||
if strings.Contains(full, "4567890abcdef") {
|
||||
t.Errorf("maskKey leaked the middle: %q", full)
|
||||
}
|
||||
}
|
||||
|
||||
// newRequest builds an unauthenticated request.
|
||||
func newRequest(method, path, body string) *http.Request {
|
||||
req, _ := http.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// newAuthedRequest builds a request carrying the given bearer key.
|
||||
func newAuthedRequest(t *testing.T, method, path, key string) *http.Request {
|
||||
t.Helper()
|
||||
req := newRequest(method, path, "")
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
return req
|
||||
}
|
||||
|
||||
// serveViaHandler pushes a request through the full handler chain (auth included).
|
||||
func serveViaHandler(t *testing.T, g *Gateway, req *http.Request) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestAPIV1RevealRequiresAdmin: the only way to read a key in the clear is the
|
||||
// explicit admin-only reveal. Without the role check this endpoint would hand
|
||||
// every source credential to any valid (even user-scoped) key.
|
||||
func TestAPIV1RevealRequiresAdmin(t *testing.T) {
|
||||
g, _, user := v1Gateway(t)
|
||||
rec := serveViaHandler(t, g, newAuthedRequest(t, http.MethodGet, "/api/v1/sources/up?reveal=credentials", user))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("user reveal = %d, want 403: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "sk-up-secret") {
|
||||
t.Error("a forbidden reveal still leaked the key")
|
||||
}
|
||||
rec = doReq(t, g, http.MethodGet, "/api/v1/sources/up?reveal=credentials", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("admin reveal = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "sk-up-secret") {
|
||||
t.Error("admin reveal should return the key in the clear (the edit form needs it)")
|
||||
}
|
||||
// The masked default view must still hide it on the same path.
|
||||
rec = doReq(t, g, http.MethodGet, "/api/v1/sources/up", "")
|
||||
if strings.Contains(rec.Body.String(), "sk-up-secret") {
|
||||
t.Error("the default view leaked the key")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@ package gateway
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/types"
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// TestMergeUsageKeepsZeroCacheDetails locks the streaming counterpart of the
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing.
|
||||
//
|
||||
// The adapters already parse completion_tokens_details.reasoning_tokens and
|
||||
// forward it (qwen38, gemini, anthropic all do), and types.go has carried
|
||||
// the field since it was defined — but nothing ever READ it. The number arrived
|
||||
// in the response and was discarded at the one place that could have kept it,
|
||||
// so a pricing rule had no way to split thinking from output.
|
||||
//
|
||||
// The property that matters most: reasoning is a SUBSET of completion. Every
|
||||
// provider measured bills it at the completion rate, so a billing plugin that
|
||||
// added it to completion_tokens would double-charge every thinking token. The
|
||||
// test asserts completion is left alone.
|
||||
func TestRecordChatUsageKeepsReasoningTokens(t *testing.T) {
|
||||
rec := &Req{}
|
||||
resp := &types.UnifiedResponse{
|
||||
TokenUsage: types.TokenUsage{
|
||||
Prompt: 100,
|
||||
Completion: 500,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{
|
||||
ReasoningTokens: 320,
|
||||
},
|
||||
},
|
||||
}
|
||||
recordChatUsage(rec, &types.ChatRequest{}, resp)
|
||||
|
||||
if rec.Reasoning != 320 {
|
||||
t.Errorf("Reasoning = %d, want 320", rec.Reasoning)
|
||||
}
|
||||
// The subset must not be folded into completion — it is already counted there.
|
||||
if rec.Compl != 500 {
|
||||
t.Errorf("Compl = %d, want 500 (reasoning is a subset, never additive)", rec.Compl)
|
||||
}
|
||||
if rec.Prompt != 100 {
|
||||
t.Errorf("Prompt = %d, want 100", rec.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordChatUsageReasoningAbsentStaysZero: "not reported" and "reported as
|
||||
// zero" must both leave the field at 0, and neither may invent a number. A
|
||||
// plugin reading Reasoning cannot tell them apart, which is why the field is
|
||||
// documented as "0 means not reported" rather than "0 means no thinking".
|
||||
func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
u types.TokenUsage
|
||||
}{
|
||||
{"no details object", types.TokenUsage{Prompt: 10, Completion: 20}},
|
||||
{"details without reasoning", types.TokenUsage{
|
||||
Prompt: 10,
|
||||
Completion: 20,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{},
|
||||
}},
|
||||
} {
|
||||
rec := &Req{}
|
||||
recordChatUsage(rec, &types.ChatRequest{}, &types.UnifiedResponse{TokenUsage: tc.u})
|
||||
if rec.Reasoning != 0 {
|
||||
t.Errorf("%s: Reasoning = %d, want 0", tc.name, rec.Reasoning)
|
||||
}
|
||||
if rec.Compl != 20 {
|
||||
t.Errorf("%s: Compl = %d, want 20", tc.name, rec.Compl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedReasoningIsRecordedOnTheRequest is the streaming counterpart.
|
||||
//
|
||||
// TestRecordChatUsageKeepsReasoningTokens covers the non-streaming path only,
|
||||
// and for a long time the streamed path dropped the number: the non-streaming
|
||||
// recorder read TokenUsage.CompletionTokensDetails, while the streaming one read
|
||||
// only Prompt and Completion from the terminal usage chunk. (Cache accounting was
|
||||
// mirrored; reasoning simply never was.)
|
||||
//
|
||||
// The symptom is invisible in the response — the client sees a correct answer
|
||||
// either way. It only shows in the audit trail, where a reasoning-heavy source's
|
||||
// streamed requests recorded completion=8166 and no reasoning at all. Found by
|
||||
// reading the production audit file, not by a failing test, which is exactly why
|
||||
// the property is pinned here.
|
||||
func TestStreamedReasoningIsRecordedOnTheRequest(t *testing.T) {
|
||||
rec := &Req{}
|
||||
applyStreamUsageDetails(rec, &types.TokenUsage{
|
||||
Prompt: 159579,
|
||||
Completion: 751,
|
||||
Total: 160330,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{
|
||||
ReasoningTokens: 512,
|
||||
},
|
||||
})
|
||||
if rec.Reasoning != 512 {
|
||||
t.Errorf("streamed Reasoning = %d, want 512 — a streamed request records no "+
|
||||
"reasoning, so a pricing rule that splits thinking from output silently "+
|
||||
"bills it as ordinary output", rec.Reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedReasoningIsNeverAddedToCompletion pins the SUBSET relation in the
|
||||
// streaming path specifically. A presence check would pass if someone wrote
|
||||
// rec.Compl += reasoning, and billing would then charge every thinking token
|
||||
// twice.
|
||||
func TestStreamedReasoningIsNeverAddedToCompletion(t *testing.T) {
|
||||
rec := &Req{}
|
||||
rec.Compl = 751 // set upstream of the call, as the streaming handler does
|
||||
applyStreamUsageDetails(rec, &types.TokenUsage{
|
||||
Completion: 751,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{ReasoningTokens: 512},
|
||||
})
|
||||
if rec.Compl != 751 {
|
||||
t.Errorf("Compl = %d, want 751 unchanged — reasoning is a subset, not an addition", rec.Compl)
|
||||
}
|
||||
if rec.Reasoning >= rec.Compl {
|
||||
t.Errorf("Reasoning (%d) must be a SUBSET of Compl (%d)", rec.Reasoning, rec.Compl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedUsageWithoutReasoningLeavesItUnset: an upstream that reports no
|
||||
// reasoning must not be turned into a reported zero. In the audit trail 0 means
|
||||
// "not reported", and a plugin cannot tell that from "no reasoning happened".
|
||||
func TestStreamedUsageWithoutReasoningLeavesItUnset(t *testing.T) {
|
||||
rec := &Req{}
|
||||
applyStreamUsageDetails(rec, &types.TokenUsage{Prompt: 10, Completion: 4, Total: 14})
|
||||
if rec.Reasoning != 0 {
|
||||
t.Errorf("Reasoning = %d for an upstream that reported none", rec.Reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedCacheAccountingStillWorks is a regression guard for the block the
|
||||
// reasoning lines were added to. They share a function, so a future edit that
|
||||
// short-circuits one would break the other — silently, since a missing cache
|
||||
// number reads as "nothing was cached".
|
||||
func TestStreamedCacheAccountingStillWorks(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
u types.TokenUsage
|
||||
hit int64
|
||||
miss int64
|
||||
rep bool
|
||||
}{
|
||||
{"details object", types.TokenUsage{
|
||||
PromptTokensDetails: &types.PromptTokensDetails{CachedTokens: 4096},
|
||||
}, 4096, 0, true},
|
||||
{"legacy hit/miss", types.TokenUsage{
|
||||
PromptCacheHit: 2048, PromptCacheMiss: 512,
|
||||
}, 2048, 512, true},
|
||||
{"nothing reported", types.TokenUsage{}, 0, 0, false},
|
||||
} {
|
||||
rec := &Req{}
|
||||
applyStreamUsageDetails(rec, &c.u)
|
||||
if rec.CacheHit != c.hit || rec.CacheMiss != c.miss || rec.CacheReported != c.rep {
|
||||
t.Errorf("%s: hit=%d miss=%d reported=%v; want %d/%d/%v",
|
||||
c.name, rec.CacheHit, rec.CacheMiss, rec.CacheReported, c.hit, c.miss, c.rep)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 客户端自带的会话标识必须被采纳:它比「首条 user 消息指纹」更准
|
||||
// (历史压缩后指纹会漂移),也是唯一能天然按会话隔离的来源。
|
||||
func TestClientSessionFromRequestHeaders(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
bodyKey string
|
||||
want string
|
||||
}{
|
||||
{"pi 默认格式 x-session-affinity", map[string]string{"X-Session-Affinity": "ses-1"}, "", "ses-1"},
|
||||
{"openrouter 格式 x-session-id", map[string]string{"X-Session-Id": "ses-2"}, "", "ses-2"},
|
||||
{"openai 格式 session_id", map[string]string{"Session-Id": "ses-3"}, "", "ses-3"},
|
||||
{"body prompt_cache_key 兜底", nil, "cache-4", "cache-4"},
|
||||
{"头优先于 body", map[string]string{"X-Session-Affinity": "ses-5"}, "cache-x", "ses-5"},
|
||||
{"空白不算", map[string]string{"X-Session-Affinity": " "}, "", ""},
|
||||
{"都没有则为空(适配器自行推导)", nil, "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
for k, v := range tc.headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
if got := clientSessionFromRequest(r, tc.bodyKey); got != tc.want {
|
||||
t.Errorf("clientSessionFromRequest = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 刻意不采纳 x-client-request-id:名字含 request,部分客户端每请求都换,
|
||||
// 拿它当会话会让上游前缀缓存永不命中。
|
||||
func TestClientRequestIDIsNotUsedAsSession(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
r.Header.Set("X-Client-Request-Id", "per-request-uuid")
|
||||
if got := clientSessionFromRequest(r, ""); got != "" {
|
||||
t.Errorf("x-client-request-id 不应被当作会话(否则缓存永不命中),got %q", got)
|
||||
}
|
||||
}
|
||||
@ -1,324 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// deploy.sh is the only release-critical script with no automated coverage, and
|
||||
// its newest part — prune_adapter_backups — deletes directories under
|
||||
// /etc/modelrouter. This exercises the function in a throwaway directory with the
|
||||
// REAL backup-name shape.
|
||||
//
|
||||
// The shape matters and got this wrong twice: the guard is
|
||||
// `^adapters\.bak\.[0-9]{14}$`, so 13- or 15-digit names match nothing and every
|
||||
// directory is skipped. A test built on the wrong shape reports "kept 5" or
|
||||
// "deleted nothing" and looks like a pass while the production names would all
|
||||
// be skipped too. The names below are copied from the live directory listing.
|
||||
func TestDeployPruneAdapterBackups(t *testing.T) {
|
||||
if _, err := os.Stat("../../deploy.sh"); err != nil {
|
||||
t.Skip("deploy.sh not present (packaging-only checkout)")
|
||||
}
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash not available")
|
||||
}
|
||||
|
||||
base := t.TempDir()
|
||||
fn, err := os.ReadFile("../../deploy.sh")
|
||||
if err != nil {
|
||||
t.Fatalf("read deploy.sh: %v", err)
|
||||
}
|
||||
src := string(fn)
|
||||
i := strings.Index(src, "prune_backups()")
|
||||
if i < 0 {
|
||||
t.Fatal("prune_backups not found in deploy.sh")
|
||||
}
|
||||
j := strings.Index(src[i:], "\n}\n")
|
||||
if j < 0 {
|
||||
t.Fatal("prune_backups has no closing brace")
|
||||
}
|
||||
body := src[i : i+j+3]
|
||||
|
||||
// Redirect ONLY the base path. The logic under test must stay verbatim.
|
||||
body = strings.Replace(body,
|
||||
`local base="/etc/modelrouter"`,
|
||||
`local base="`+base+`"`, 1)
|
||||
// KEEP_BACKUPS must come from deploy.sh itself, NOT from a literal
|
||||
// here: hardcoding 5 in the shim overrode whatever the script configured, so
|
||||
// a mutation that set KEEP_BACKUPS=0 in deploy.sh still passed. The
|
||||
// value is part of what is under test.
|
||||
var keepRe = regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
|
||||
keepM := keepRe.FindStringSubmatch(src)
|
||||
if keepM == nil {
|
||||
t.Fatal("deploy.sh no longer sets KEEP_BACKUPS; the pruning " +
|
||||
"policy would be undefined")
|
||||
}
|
||||
keep := keepM[1]
|
||||
|
||||
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" + keep + "\n" +
|
||||
body + "\nprune_backups adapters\n"
|
||||
scriptPath := filepath.Join(base, "fn.sh")
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
|
||||
// 12 well-formed backups. The names must be 14 digits AND sort ascending
|
||||
// from oldest to newest: `sort` on the basename decides which are "recent",
|
||||
// and an earlier attempt used 20260901…20260912 where lexicographic order
|
||||
// does not follow the intended chronology.
|
||||
for _, ts := range []string{
|
||||
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
|
||||
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
|
||||
"20260901120000", "20261001120000", "20261101120000", "20261201120000",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+ts), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Names the guard must refuse to delete: a hand-made directory, a 15-digit
|
||||
// name, and an 11-digit one.
|
||||
for _, name := range []string{"manual", "202609011200000", "2026090112000"} {
|
||||
if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil {
|
||||
t.Fatalf("prune_backups failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
got[e.Name()] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 12 well-formed backups with KEEP_BACKUPS=5 → the newest FIVE
|
||||
// survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing
|
||||
// only three keepers made this assertion wrong in BOTH directions at first:
|
||||
// …09 was named as "should be deleted" while it is in fact retained.
|
||||
// KEEP_BACKUPS=5 is a cap on the TOTAL number of backups, and
|
||||
// irregular names are never deleted — so they consume slots rather than
|
||||
// being ignored. With 3 unremovable directories present, only 2 of the
|
||||
// well-formed ones can survive. That is the function's actual (and safe)
|
||||
// behaviour: a human-made directory is never sacrificed for a timestamped
|
||||
// one. Assert the real outcome rather than the KEEP value.
|
||||
var wellFormedLeft []string
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "adapters.bak.") &&
|
||||
len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 {
|
||||
wellFormedLeft = append(wellFormedLeft, e.Name())
|
||||
}
|
||||
}
|
||||
if len(wellFormedLeft) == 0 {
|
||||
t.Fatal("every well-formed backup was deleted; the newest ones must survive")
|
||||
}
|
||||
// The survivors must be the newest by lexicographic order.
|
||||
if want := "adapters.bak.20261201120000"; !got[want] {
|
||||
t.Errorf("%s was deleted but it is the newest timestamp", want)
|
||||
}
|
||||
if got["adapters.bak.20260101120000"] {
|
||||
t.Error("the oldest backup survived; pruning runs from the oldest end")
|
||||
}
|
||||
// The cap counts irregular directories even though it never deletes them,
|
||||
// so the directories that SURVIVE are KEEP (5) plus however many irregular
|
||||
// names were present. Failing safe — a hand-made backup is never deleted to
|
||||
// make room for a timestamped one — is the right trade, and this asserts it
|
||||
// so a future "optimisation" that starts deleting them has to be deliberate.
|
||||
var irregular, regular int
|
||||
for _, e := range entries {
|
||||
if !strings.HasPrefix(e.Name(), "adapters.bak.") {
|
||||
continue
|
||||
}
|
||||
if len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 {
|
||||
regular++
|
||||
} else {
|
||||
irregular++
|
||||
}
|
||||
}
|
||||
if want := 5; regular+irregular > want {
|
||||
// 3 irregular were seeded and cannot be deleted, so KEEP is effectively
|
||||
// consumed by them.
|
||||
if regular+irregular-3 > want {
|
||||
t.Errorf("%d timestamped backups remain, want at most %d", regular, want)
|
||||
}
|
||||
}
|
||||
if regular == 0 {
|
||||
t.Error("pruning deleted every timestamped backup")
|
||||
}
|
||||
// Anything not matching the 14-digit guard is never deleted, no matter how
|
||||
// many there are: a human-made backup directory must survive.
|
||||
for _, name := range []string{"manual", "202609011200000", "2026090112000"} {
|
||||
if !got["adapters.bak."+name] {
|
||||
t.Errorf("adapters.bak.%s was deleted; the guard only matches "+
|
||||
"^adapters\\.bak\\.[0-9]{14}$", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeployBacksUpTheLiveFileBeforeOverwriting guards the mistake that makes a
|
||||
// rollback useless: `cp <new source> <.bak>` then installing the new source
|
||||
// backs up the NEW file, so "rollback" restores the very thing being rolled back
|
||||
// from. The backup must be taken from the target path.
|
||||
func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) {
|
||||
b, err := os.ReadFile("../../deploy.sh")
|
||||
if err != nil {
|
||||
t.Skip("deploy.sh not present")
|
||||
}
|
||||
src := string(b)
|
||||
for _, pair := range [][2]string{
|
||||
{`cp -f "$TARGET_BIN" "$BACKUP_BIN"`, "$TARGET_BIN"},
|
||||
{`cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"`, "$TARGET_CONFIG"},
|
||||
} {
|
||||
stmt, from := pair[0], pair[1]
|
||||
if !strings.Contains(src, stmt) {
|
||||
t.Errorf("expected the pre-install backup %q in deploy.sh; without it "+
|
||||
"a failed deploy has nothing to roll back to", stmt)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(stmt, from) {
|
||||
t.Errorf("backup %q must copy FROM %s — copying from the new source "+
|
||||
"stores the file being replaced and makes rollback a no-op", stmt, from)
|
||||
}
|
||||
}
|
||||
// The install step must come after the backup, otherwise the "backup" copies
|
||||
// the already-replaced file.
|
||||
backupAt := strings.Index(src, `cp -f "$TARGET_BIN" "$BACKUP_BIN"`)
|
||||
installAt := strings.Index(src, `mv -f "$STAGING_BIN" "$TARGET_BIN"`)
|
||||
if backupAt < 0 || installAt < 0 {
|
||||
t.Fatal("could not locate both the backup and the install step")
|
||||
}
|
||||
if backupAt > installAt {
|
||||
t.Error("the binary is installed before it is backed up; the backup then " +
|
||||
"captures the new file and rollback restores nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 备份清理必须覆盖 plugins 前缀(真实缺陷)。
|
||||
//
|
||||
// 事实:backup 清理最初只处理 `adapters.bak.*`,而 `sync_plugins` 同样每次部署
|
||||
// 建一个 `plugins.bak.<时间戳>` —— 于是 adapters 的堆积被修好,plugins 继续涨。
|
||||
// 实测线上 plugins.bak 积累到 **36 个**、/etc/modelrouter 涨到 192M。
|
||||
//
|
||||
// 这是「两处只修一处」的老形状。判据钉住:prune_all_backups 必须同时裁两个前缀,
|
||||
// 且新增任何 .bak.<时间戳> 前缀时都要在这里补上(否则又是一个泄漏点)。
|
||||
func TestDeployPruneCoversBothBackupPrefixes(t *testing.T) {
|
||||
if _, err := os.Stat("../../deploy.sh"); err != nil {
|
||||
t.Skip("deploy.sh not present (packaging-only checkout)")
|
||||
}
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash not available")
|
||||
}
|
||||
fn, err := os.ReadFile("../../deploy.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(fn)
|
||||
|
||||
// ① prune_all_backups 必须存在且两个前缀都裁
|
||||
i := strings.Index(src, "prune_all_backups()")
|
||||
if i < 0 {
|
||||
t.Fatal("prune_all_backups 不存在 —— 调用点若仍只裁 adapters," +
|
||||
"plugins.bak 会继续无上限堆积(实测 36 个 / 192M)")
|
||||
}
|
||||
j := strings.Index(src[i:], "\n}\n")
|
||||
if j < 0 {
|
||||
t.Fatal("prune_all_backups 没有收尾大括号")
|
||||
}
|
||||
body := src[i : i+j+3]
|
||||
for _, want := range []string{"prune_backups adapters", "prune_backups plugins"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("prune_all_backups 缺 %q —— plugins.bak 会无上限堆积", want)
|
||||
}
|
||||
}
|
||||
|
||||
// ② ★ 覆盖完整性:脚本里出现的每个 `.bak.<时间戳>` 前缀都必须在裁剪范围内。
|
||||
// 这条比逐字检查两个名字更强 —— 将来有人新增第三种备份目录(比如
|
||||
// runtime.bak.*),这里会立刻报红,而不是等它涨到几百兆。
|
||||
prefixRe := regexp.MustCompile(`([a-z_]+)\.bak\.\$\(date`)
|
||||
found := map[string]bool{}
|
||||
for _, m := range prefixRe.FindAllStringSubmatch(src, -1) {
|
||||
found[m[1]] = true
|
||||
}
|
||||
if len(found) == 0 {
|
||||
t.Fatal("deploy.sh 里没找到任何 .bak.$(date …) 备份目录创建点(判据需更新)")
|
||||
}
|
||||
for prefix := range found {
|
||||
if !strings.Contains(body, "prune_backups "+prefix) {
|
||||
t.Errorf("deploy.sh 会创建 %s.bak.<时间戳> 目录,但 prune_all_backups 不裁它 "+
|
||||
"—— 它就是一个无上限增长的备份泄漏点(实测 plugins.bak 涨到 36 个)", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 行为验证:用真函数裁 plugins 前缀,最新 5 份必须留下、更旧的删掉
|
||||
base := t.TempDir()
|
||||
k := strings.Index(src, "prune_backups()")
|
||||
kj := strings.Index(src[k:], "\n}\n")
|
||||
fnBody := src[k : k+kj+3]
|
||||
fnBody = strings.Replace(fnBody, `local base="/etc/modelrouter"`, `local base="`+base+`"`, 1)
|
||||
keepRe := regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
|
||||
km := keepRe.FindStringSubmatch(src)
|
||||
if km == nil {
|
||||
t.Fatal("deploy.sh 不再设置 KEEP_BACKUPS")
|
||||
}
|
||||
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
|
||||
km[1] + "\n" + fnBody + "\nprune_backups plugins\n"
|
||||
sp := filepath.Join(base, "fn.sh")
|
||||
if err := os.WriteFile(sp, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ts := range []string{
|
||||
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
|
||||
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Join(base, "plugins.bak."+ts), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// adapters 前缀**不该被动**(防止误删别的前缀)
|
||||
if err := os.MkdirAll(filepath.Join(base, "adapters.bak.20260101120000"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := exec.Command("bash", sp).CombinedOutput(); err != nil {
|
||||
t.Fatalf("prune_backups plugins failed: %v\n%s", err, out)
|
||||
}
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
got[e.Name()] = true
|
||||
}
|
||||
}
|
||||
if !got["plugins.bak.20260801120000"] {
|
||||
t.Error("最新的 plugins 备份被删了;裁剪必须从最旧的一端开始")
|
||||
}
|
||||
if got["plugins.bak.20260101120000"] {
|
||||
t.Error("最旧的 plugins 备份存活;裁剪没生效")
|
||||
}
|
||||
if !got["adapters.bak.20260101120000"] {
|
||||
t.Error("裁 plugins 时误删了 adapters 备份 —— 前缀参数必须严格限定范围")
|
||||
}
|
||||
|
||||
// ④ 未知前缀必须响亮失败(防误用:传空串会删掉整个目录)
|
||||
bad := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
|
||||
km[1] + "\n" + fnBody + "\nprune_backups \"\"\n"
|
||||
bp := filepath.Join(base, "bad.sh")
|
||||
if err := os.WriteFile(bp, []byte(bad), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := exec.Command("bash", bp).CombinedOutput(); err == nil {
|
||||
t.Fatalf("prune_backups 接受空前缀(危险:会删整个目录),输出: %s", out)
|
||||
}
|
||||
}
|
||||
@ -1,749 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// Model discovery: turn "here is a URL and a key" into a configured source.
|
||||
//
|
||||
// The hard part is not the GET /models call — it is guessing which URL the
|
||||
// upstream actually serves. This gateway builds request paths as
|
||||
// base_url + adapter endpoint (openai contributes "/chat/completions"), so a
|
||||
// base_url that already ends in "/v1" yields "/v1/chat/completions" while a
|
||||
// base_url of "/v1" plus our default image path yields the broken
|
||||
// "/v1/v1/images/generations". Vendors disagree on whether the version segment
|
||||
// belongs to the base or to the path, and there is no header that says which.
|
||||
//
|
||||
// So discovery does not trust the URL it is given. It tries a small, ordered
|
||||
// set of candidates, records which one answered, and reports that back as the
|
||||
// normalized base_url + adapter endpoint pair. A caller who typed the wrong
|
||||
// form gets a working source anyway, plus the correction needed to make it
|
||||
// permanent.
|
||||
//
|
||||
// What is deliberately NOT here:
|
||||
//
|
||||
// - No guessing of model capabilities from the name. "vision" in an id does
|
||||
// not make a model accept images, and marking an image-only endpoint as
|
||||
// kind:"chat" makes every AUTO request fail on it. Kind stays "chat" unless
|
||||
// the upstream's own metadata says otherwise (see discoveredModelKind).
|
||||
// - No live probe per model. A single-token completion per model would cost
|
||||
// real money across a 34-model catalog and takes minutes. /models is the
|
||||
// cheap, authoritative-enough signal for "this id is served here"; whether
|
||||
// it actually answers stays for the normal request path to discover.
|
||||
//
|
||||
// The one thing that IS verified live is reachability, because a source that
|
||||
// 404s is not worth writing to disk.
|
||||
|
||||
const (
|
||||
// discoveryTimeout bounds the whole probe. Vendors are routinely slow on
|
||||
// the first request to an uncached endpoint; 20s is enough for a healthy
|
||||
// one and short enough that the UI is not left hanging.
|
||||
discoveryTimeout = 20 * time.Second
|
||||
|
||||
// discoveryModelsPath is the OpenAI-compatible listing endpoint. Nearly
|
||||
// every vendor proxies it verbatim, including the ones whose chat path
|
||||
// differs.
|
||||
discoveryModelsPath = "/v1/models"
|
||||
|
||||
// discoveryMaxBody caps the listing response. A catalog with thousands of
|
||||
// models is still far under this; anything larger is a captive portal or
|
||||
// an error page, not a model list.
|
||||
discoveryMaxBody = 8 << 20
|
||||
)
|
||||
|
||||
// discoveredModel is one entry of a parsed /models response, kept in the
|
||||
// upstream's own terms. The upstream is the only authority on what it serves.
|
||||
type discoveredModel struct {
|
||||
ID string
|
||||
Kind string // "chat" | "image"
|
||||
Meta map[string]interface{}
|
||||
}
|
||||
|
||||
// discoveryResult reports what a probe found, including the case where nothing
|
||||
// worked — the failure text is what the operator needs, so it must name every
|
||||
// URL that was tried rather than just the last one.
|
||||
type discoveryResult struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
// ImageEndpoint is reported separately because the gateway composes it
|
||||
// differently: Provider.ImageURL is BaseURL + ImageEndpoint(), and the
|
||||
// default ImageEndpoint is the ABSOLUTE path "/v1/images/generations".
|
||||
// Chat's default is the relative "/chat/completions". So a base_url that
|
||||
// already ends in /v1 produces a working chat URL and a broken image URL
|
||||
// ("/v1/v1/images/generations") — which is exactly the 404 seen on the
|
||||
// first vela configuration, and which discovery has to correct.
|
||||
ImageEndpoint string `json:"image_endpoint"`
|
||||
Adapter string `json:"adapter"`
|
||||
ModelsPath string `json:"models_path"`
|
||||
Models []string `json:"models"`
|
||||
ImageModels []string `json:"image_models"`
|
||||
Tried []string `json:"tried"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON guarantees the two list fields are always JSON arrays, never
|
||||
// null. The WebUI concatenates them directly, and "null" renders as the string
|
||||
// "undefined" in every row — a failure that only shows up in a browser and not
|
||||
// in a JSON schema check.
|
||||
//
|
||||
// Relying on the constructor to initialize the slices is not enough: the zero
|
||||
// value of this struct is a legitimate thing for a caller to build, and a
|
||||
// future code path that forgets the initializer would ship a UI bug silently.
|
||||
func (r discoveryResult) MarshalJSON() ([]byte, error) {
|
||||
type alias discoveryResult // avoid recursion
|
||||
out := alias(r)
|
||||
if out.Models == nil {
|
||||
out.Models = []string{}
|
||||
}
|
||||
if out.ImageModels == nil {
|
||||
out.ImageModels = []string{}
|
||||
}
|
||||
if out.Tried == nil {
|
||||
out.Tried = []string{}
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// discoveryCandidate is one (base_url, models-path) guess. Order matters: the
|
||||
// first candidate is the one reported as normalized when several work, because
|
||||
// it is the one that keeps base_url free of a version segment and therefore
|
||||
// composes correctly with the adapter's chat path.
|
||||
type discoveryCandidate struct {
|
||||
baseURL string
|
||||
modelsPath string
|
||||
// prefer ranks candidates that are more likely to be the vendor's real
|
||||
// path. A 200 from a lower-preference candidate is still accepted, but
|
||||
// only after every higher-preference one failed — see DiscoverModels.
|
||||
prefer int
|
||||
}
|
||||
|
||||
// discoveryFailure carries the HTTP status the caller should answer with.
|
||||
//
|
||||
// The status is part of the error rather than left to the handler because the
|
||||
// whole point of classifying a failure is that the operator must not have to
|
||||
// guess whether the key, the endpoint, or the vendor is at fault.
|
||||
type discoveryFailure struct {
|
||||
msg string
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *discoveryFailure) Error() string { return e.msg }
|
||||
|
||||
// StatusOrDefault reports the intended HTTP status.
|
||||
func (e *discoveryFailure) StatusOrDefault() int {
|
||||
if e.status == 0 {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
return e.status
|
||||
}
|
||||
|
||||
// discoveryCandidates enumerates the URL forms worth trying for a raw input.
|
||||
//
|
||||
// The ranking encodes a real observation rather than a guess: the amr-link
|
||||
// endpoint serves its catalog at "/v1/models" (the form an SDK example shows)
|
||||
// from a base the operator pasted WITH the "/v1", while several other vendors
|
||||
// mount it at the host root. Both are probed; the one that answers is reported
|
||||
// back as a normalized base/endpoint pair.
|
||||
//
|
||||
// What we are really deciding is the base_url to persist. The safest one keeps
|
||||
// the version segment on the ENDPOINT, because openai's adapter endpoint is
|
||||
// "/chat/completions" and the two are concatenated: base "https://h" +
|
||||
// endpoint "/v1/chat/completions" is correct, while leaving "/v1" on the base
|
||||
// doubles the default image path into "/v1/v1/images/generations" — which is
|
||||
// exactly the 404 observed on the first vela configuration attempt.
|
||||
func discoveryCandidates(raw string) []discoveryCandidate {
|
||||
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
// Deduplicate on the URL that will actually be requested, not on the
|
||||
// (base, path) pair. base "https://h/v1" + path "/models" and base "https://h"
|
||||
// + path "/v1/models" both request the same address, and listing it twice in
|
||||
// an error message makes the operator think two things were tried.
|
||||
seenURL := map[string]bool{}
|
||||
seenPair := map[string]bool{}
|
||||
var out []discoveryCandidate
|
||||
add := func(base, path string, prefer int) {
|
||||
pair := base + "|" + path
|
||||
if seenPair[pair] || seenURL[base+path] {
|
||||
return
|
||||
}
|
||||
seenPair[pair] = true
|
||||
seenURL[base+path] = true
|
||||
out = append(out, discoveryCandidate{baseURL: base, modelsPath: path, prefer: prefer})
|
||||
}
|
||||
|
||||
stripped := trimVersionSuffix(raw)
|
||||
hasVersion := stripped != raw
|
||||
|
||||
// Both catalog paths are always tried, whichever form the operator typed.
|
||||
//
|
||||
// The earlier version offered "/models" only when the base already carried a
|
||||
// version segment. That left a bare host ("https://api.vendor.com") with a
|
||||
// SINGLE candidate and therefore no fallback at all — so a vendor serving
|
||||
// its catalog at the bare "/models" was reported as unreachable. Every
|
||||
// test used a "/v1"-suffixed base and so never saw it; siliconflow's
|
||||
// "/v1/models" happens to answer, which is why the gap survived review.
|
||||
//
|
||||
// Preference only decides WHICH URL is reported when several work, never
|
||||
// which ones are attempted.
|
||||
//
|
||||
// Order matters: a base that already ends in a version segment must be
|
||||
// paired with the RELATIVE "/models" first, because pairing it with
|
||||
// "/v1/models" produces "…/v1/v1/models" — a URL that is 404 by construction.
|
||||
// Trying it first would spend a round trip on a certain miss and, worse,
|
||||
// make the reported "tried" list lead with nonsense.
|
||||
if hasVersion {
|
||||
add(raw, "/models", 0)
|
||||
add(raw, discoveryModelsPath, 1) // the doubled form; kept only as a last resort
|
||||
add(stripped, "/models", 2)
|
||||
add(stripped, discoveryModelsPath, 3)
|
||||
} else {
|
||||
add(raw, discoveryModelsPath, 0)
|
||||
add(raw, "/models", 1)
|
||||
}
|
||||
|
||||
// Stable insertion sort by preference; add() above is not ordered.
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j].prefer < out[j-1].prefer; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trimVersionSuffix removes a trailing "/v1" (or /v2, /v1beta, ...) so the
|
||||
// host root can be probed on its own.
|
||||
func trimVersionSuffix(base string) string {
|
||||
i := strings.LastIndex(base, "/")
|
||||
if i < 0 {
|
||||
return base
|
||||
}
|
||||
if !isVersionSegment(base[i+1:]) {
|
||||
return base
|
||||
}
|
||||
return base[:i]
|
||||
}
|
||||
|
||||
// versionSegment returns the leading path segment of a models path when that
|
||||
// segment is a version marker ("/v1/models" → "/v1", "/models" → ""). It is the
|
||||
// piece that must move between base_url and endpoint so the two compose.
|
||||
func versionSegment(modelsPath string) string {
|
||||
parts := strings.Split(strings.Trim(modelsPath, "/"), "/")
|
||||
if len(parts) != 2 || !isVersionSegment(parts[0]) {
|
||||
return ""
|
||||
}
|
||||
return "/" + parts[0]
|
||||
}
|
||||
|
||||
// isVersionSegment reports whether a path segment looks like "v1", "v2",
|
||||
// "v1beta" — and not like a real path name such as "vision" or a wildcard
|
||||
// path such as "v1x".
|
||||
//
|
||||
// The rule is: a leading "v", then digits, then an OPTIONAL suffix drawn from
|
||||
// the prerelease markers vendors actually use. A free-form alphabetic tail was
|
||||
// tried and rejected — it classifies "v1x" as a version, and that is how a
|
||||
// templated path segment would get stripped off a real base URL.
|
||||
func isVersionSegment(seg string) bool {
|
||||
if len(seg) < 2 || seg[0] != 'v' {
|
||||
return false
|
||||
}
|
||||
i := 1
|
||||
digits := 0
|
||||
for ; i < len(seg); i++ {
|
||||
if seg[i] < '0' || seg[i] > '9' {
|
||||
break
|
||||
}
|
||||
digits++
|
||||
}
|
||||
if digits == 0 {
|
||||
return false
|
||||
}
|
||||
suffix := strings.ToLower(seg[i:])
|
||||
if suffix == "" {
|
||||
return true
|
||||
}
|
||||
for _, marker := range []string{"alpha", "beta", "preview", "dev", "rc"} {
|
||||
if suffix == marker || strings.HasPrefix(suffix, "-"+marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// composeEndpoint derives the chat endpoint to persist, given the models path
|
||||
// that actually answered, the base_url being persisted, and the base the
|
||||
// operator typed.
|
||||
//
|
||||
// The rule is a single invariant: exactly one version segment must end up in
|
||||
// base_url + endpoint. The adapter contributes "/chat/completions" and the
|
||||
// default image path contributes "/v1/images/generations", so a version left on
|
||||
// BOTH breaks one of them — and one left on NEITHER sends chat to the wrong
|
||||
// path on vendors that require the segment.
|
||||
func composeEndpoint(modelsPath, persistedBase, typedBase string) string {
|
||||
// The version segment currently present on the persisted base.
|
||||
onBase := ""
|
||||
if b := trimVersionSuffix(persistedBase); b != persistedBase {
|
||||
onBase = persistedBase[len(b):]
|
||||
}
|
||||
// The version segment present in the models path that answered.
|
||||
inPath := versionSegment(modelsPath)
|
||||
|
||||
// The version segment actually WORKED: prefer the models path's, then the
|
||||
// one the operator typed.
|
||||
version := inPath
|
||||
if version == "" && onBase == "" {
|
||||
if b := trimVersionSuffix(typedBase); b != typedBase && typedBase != "" {
|
||||
version = typedBase[len(b):]
|
||||
}
|
||||
}
|
||||
if version == "" {
|
||||
return "" // adapter default already composes
|
||||
}
|
||||
return version + "/chat/completions"
|
||||
}
|
||||
|
||||
// composeImageEndpoint derives the image endpoint to persist.
|
||||
//
|
||||
// Provider.ImageURL is `base_url + ImageEndpoint()`, and the built-in default
|
||||
// is the absolute path "/v1/images/generations" — unlike chat's relative
|
||||
// "/chat/completions". So the two defaults do NOT agree about who owns the
|
||||
// version segment, which is the trap this function exists to close:
|
||||
//
|
||||
// base "https://h" + "/v1/images/generations" → correct
|
||||
// base "https://h/v1" + "/v1/images/generations" → "/v1/v1/…" 404
|
||||
// base "https://h/v1" + "/images/generations" → "/v1/images/generations" correct
|
||||
//
|
||||
// The second row is not a theoretical guess. Measured against the real
|
||||
// amr-link endpoint with base_url "https://amr-link.open-design.ai/v1":
|
||||
//
|
||||
// …/v1/images/generations → 200 (the image is then generated; that call
|
||||
// takes minutes, which is the upstream's own
|
||||
// latency and not a routing problem)
|
||||
// …/v1/v1/images/generations → 404
|
||||
// …/images/generations → 404
|
||||
//
|
||||
// Leaving the default in place when the base already ends in a version is
|
||||
// therefore always wrong for such an upstream, and the version-relative form
|
||||
// is the one that composes.
|
||||
func composeImageEndpoint(persistedBase string) string {
|
||||
if b := trimVersionSuffix(persistedBase); b != persistedBase {
|
||||
return "/images/generations"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DiscoverModels probes rawURL for a model catalog. apiKey is sent as a bearer
|
||||
// token; some gateways accept an unauthenticated listing, so an empty key is
|
||||
// allowed and simply omits the header.
|
||||
//
|
||||
// It returns on the first candidate that yields a parseable catalog, so the
|
||||
// reported latency is that of the working path, not the sum of the failures.
|
||||
func DiscoverModels(ctx context.Context, rawURL, apiKey string) (*discoveryResult, error) {
|
||||
cands := discoveryCandidates(rawURL)
|
||||
if len(cands) == 0 {
|
||||
return nil, fmt.Errorf("base url required")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: discoveryTimeout}
|
||||
var tried []string
|
||||
var firstErr string
|
||||
var authErr string
|
||||
typedBase := strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
||||
|
||||
var lastErr string
|
||||
for _, c := range cands {
|
||||
full := c.baseURL + c.modelsPath
|
||||
tried = append(tried, full)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
|
||||
if err != nil {
|
||||
firstErr = err.Error()
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = classifyTransport(err)
|
||||
if lastErr == "" {
|
||||
lastErr = err.Error()
|
||||
}
|
||||
continue
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, discoveryMaxBody))
|
||||
resp.Body.Close()
|
||||
took := time.Since(start)
|
||||
if readErr != nil {
|
||||
firstErr = readErr.Error()
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// An auth rejection is the operator's key being wrong, not the
|
||||
// upstream being down. Keep it: it is the single most common
|
||||
// failure and 502 would send them to the wrong place.
|
||||
if af := classifyAuthStatus(resp.StatusCode, body); af != "" {
|
||||
authErr = af
|
||||
continue
|
||||
}
|
||||
firstErr = fmt.Sprintf("%s returned %d", full, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
models, err := parseModelCatalog(body)
|
||||
if err != nil {
|
||||
firstErr = fmt.Sprintf("%s: %v", full, err)
|
||||
continue
|
||||
}
|
||||
|
||||
res := &discoveryResult{
|
||||
BaseURL: c.baseURL,
|
||||
ModelsPath: c.modelsPath,
|
||||
Adapter: "openai",
|
||||
Models: []string{},
|
||||
ImageModels: []string{},
|
||||
Tried: append([]string{}, tried...),
|
||||
LatencyMS: took.Milliseconds(),
|
||||
}
|
||||
// Persist a base/endpoint pair that composes correctly. The adapter's
|
||||
// own endpoint is "/chat/completions", so any version segment has to be
|
||||
// carried on the endpoint rather than left on the base (where it doubles
|
||||
// the default image path into "/v1/v1/images/generations" — the exact
|
||||
// 404 seen on the first vela configuration attempt).
|
||||
res.Endpoint = composeEndpoint(c.modelsPath, c.baseURL, typedBase)
|
||||
res.ImageEndpoint = composeImageEndpoint(c.baseURL)
|
||||
|
||||
// If the operator typed a version segment and we are persisting a base
|
||||
// that no longer has it, the endpoint above is what restores it. If the
|
||||
// base kept the segment, the adapter default is already correct.
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, m := range models {
|
||||
if m.ID == "" || seen[m.ID] {
|
||||
continue
|
||||
}
|
||||
seen[m.ID] = true
|
||||
if m.Kind == "image" {
|
||||
res.ImageModels = append(res.ImageModels, m.ID)
|
||||
} else {
|
||||
res.Models = append(res.Models, m.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(res.Models)
|
||||
sort.Strings(res.ImageModels)
|
||||
if len(res.Models) == 0 && len(res.ImageModels) == 0 {
|
||||
firstErr = fmt.Sprintf("%s: catalog contained no usable model ids", full)
|
||||
continue
|
||||
}
|
||||
if c.baseURL != typedBase {
|
||||
res.Note = fmt.Sprintf("base url normalized from %s to %s; endpoint set to %q so it composes with the adapter",
|
||||
typedBase, c.baseURL, res.Endpoint)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
return nil, buildDiscoveryError(tried, authErr, firstErr, lastErr)
|
||||
}
|
||||
|
||||
// classifyAuthStatus turns a 401/403 from the catalog endpoint into a message
|
||||
// that names the credential as the cause.
|
||||
//
|
||||
// This distinction is the whole point: a wrong key and an unreachable vendor
|
||||
// are the two most common discovery failures, and they need opposite actions
|
||||
// (re-type the key vs. go check the vendor). Reporting both as "upstream
|
||||
// error" makes the operator guess.
|
||||
func classifyAuthStatus(status int, body []byte) string {
|
||||
if status != http.StatusUnauthorized && status != http.StatusForbidden {
|
||||
return ""
|
||||
}
|
||||
snippet := strings.TrimSpace(string(body))
|
||||
// Vendors put the reason in wildly different shapes; a compact quote is
|
||||
// more useful than a parse attempt that fails on most of them.
|
||||
snippet = strings.Join(strings.Fields(snippet), " ")
|
||||
if len(snippet) > 200 {
|
||||
snippet = snippet[:200] + "…"
|
||||
}
|
||||
return fmt.Sprintf("upstream rejected the api key (HTTP %d): %s — "+
|
||||
"check the key, not the endpoint", status, snippet)
|
||||
}
|
||||
|
||||
// classifyTransport turns low-level network failures into something an
|
||||
// operator can act on. An empty result means "no specific advice", and the
|
||||
// caller keeps the raw error.
|
||||
func classifyTransport(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
lower := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(lower, "no such host"):
|
||||
return "hostname does not resolve: " + msg
|
||||
case strings.Contains(lower, "certificate"), strings.Contains(lower, "tls"):
|
||||
return "TLS verification failed: " + msg
|
||||
case strings.Contains(lower, "connection refused"):
|
||||
return "connection refused: " + msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildDiscoveryError assembles the final error, preferring the most specific
|
||||
// diagnosis available. Every URL tried is listed: an operator staring at a
|
||||
// failed probe needs to know the search actually happened, not just the last
|
||||
// attempt.
|
||||
func buildDiscoveryError(tried []string, authErr, firstErr, lastErr string) error {
|
||||
triedPart := "no model catalog found"
|
||||
if len(tried) > 0 {
|
||||
triedPart = "no model catalog found; tried " + strings.Join(tried, ", ")
|
||||
}
|
||||
if authErr != "" {
|
||||
return &discoveryFailure{status: http.StatusUnauthorized, msg: authErr + " (" + triedPart + ")"}
|
||||
}
|
||||
detail := firstErr
|
||||
if detail == "" {
|
||||
detail = lastErr
|
||||
}
|
||||
if detail == "" {
|
||||
return errors.New(triedPart)
|
||||
}
|
||||
if lastErr != "" && lastErr != firstErr {
|
||||
detail += " (last: " + lastErr + ")"
|
||||
}
|
||||
return &discoveryFailure{status: http.StatusBadGateway, msg: triedPart + "; " + detail}
|
||||
}
|
||||
|
||||
// upstreamModel is the subset of a /models entry this code reads. Vendors add
|
||||
// fields freely and rename the nesting; only id is universal, and the modality
|
||||
// hints below are read opportunistically.
|
||||
type modelCaps struct {
|
||||
ImageGeneration bool `json:"image_generation"`
|
||||
Vision bool `json:"vision"`
|
||||
}
|
||||
|
||||
type upstreamModel struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Kind string `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
Modality string `json:"modality"`
|
||||
Architecture struct {
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
OutputModalities []string `json:"output_modalities"`
|
||||
} `json:"architecture"`
|
||||
Capabilities modelCaps `json:"capabilities"`
|
||||
|
||||
// Metadata is kept as raw JSON on purpose. The real amr-link catalog nests
|
||||
// cost/capability/context_limit here, and a typed struct would silently
|
||||
// discard every key this build does not know about — the operator would
|
||||
// then lose the context limit their vendor published. Raw also lets
|
||||
// input_modalities/output_modalities be read without a second type.
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
// metadataFields lists the keys worth surfacing in the UI. They are copied
|
||||
// through verbatim and nothing is inferred from them; an upstream that sends
|
||||
// no context limit must leave that absent rather than have this code write 0.
|
||||
var metadataFields = []string{
|
||||
"context_length", "context_window", "max_context_length",
|
||||
"context_limit", "context_budget", "cost", "capability",
|
||||
}
|
||||
|
||||
// parseModelCatalog reads the OpenAI list shape {"data":[...]} and the bare
|
||||
// array shape some gateways return. A response that is valid JSON but has
|
||||
// neither shape is an error, not an empty catalog: reporting "0 models" for a
|
||||
// login page or a rate-limit JSON would silently create an empty source.
|
||||
func parseModelCatalog(body []byte) ([]discoveredModel, error) {
|
||||
// "null" unmarshals into a nil slice without error, and an HTML login page
|
||||
// fails to unmarshal at all. Both must be errors.
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nil, fmt.Errorf("empty response")
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Data []upstreamModel `json:"data"`
|
||||
Models []upstreamModel `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrapper); err == nil {
|
||||
if len(wrapper.Data) > 0 {
|
||||
return toDiscovered(wrapper.Data), nil
|
||||
}
|
||||
if len(wrapper.Models) > 0 {
|
||||
return toDiscovered(wrapper.Models), nil
|
||||
}
|
||||
// A well-formed but empty catalog is a legitimate answer from a key
|
||||
// that has no models provisioned. Distinguish it from "not a catalog"
|
||||
// by checking the shape actually parsed.
|
||||
var probe map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &probe) == nil {
|
||||
if _, ok := probe["data"]; ok {
|
||||
return nil, nil
|
||||
}
|
||||
if _, ok := probe["models"]; ok {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
var bare []upstreamModel
|
||||
if err := json.Unmarshal(body, &bare); err == nil {
|
||||
return toDiscovered(bare), nil
|
||||
}
|
||||
return nil, fmt.Errorf("response is not a model catalog")
|
||||
}
|
||||
|
||||
func toDiscovered(in []upstreamModel) []discoveredModel {
|
||||
out := make([]discoveredModel, 0, len(in))
|
||||
for _, m := range in {
|
||||
out = append(out, discoveredModel{
|
||||
ID: m.ID,
|
||||
Kind: discoveredModelKind(m),
|
||||
Meta: modelMeta(m),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// discoveredModelKind trusts the upstream's own modality fields and nothing
|
||||
// else. It never infers from the model NAME: ids like "gpt-image-1" or
|
||||
// "ideogram-v2" appear on catalogs that also serve chat, and a wrong kind
|
||||
// poisons the AUTO chain (an image-only id selected for a chat request fails
|
||||
// every tier behind it).
|
||||
func discoveredModelKind(m upstreamModel) string {
|
||||
modIn := append(append([]string{}, m.Architecture.InputModalities...),
|
||||
modalityList(m.Metadata, "input_modalities")...)
|
||||
modOut := append(append([]string{}, m.Architecture.OutputModalities...),
|
||||
modalityList(m.Metadata, "output_modalities")...)
|
||||
|
||||
has := func(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if strings.EqualFold(strings.TrimSpace(v), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if m.Capabilities.ImageGeneration || has(modOut, "image") {
|
||||
return "image"
|
||||
}
|
||||
if has(modIn, "image") || has(modIn, "file") || has(modIn, "audio") || m.Capabilities.Vision {
|
||||
return "chat" // multimodal input still means a chat model
|
||||
}
|
||||
// Some catalogs encode modality as a top-level string instead of a list.
|
||||
// Only the image-ish spellings count: "chat" here means the upstream said
|
||||
// chat, and treating that as "unknown" would ignore its statement.
|
||||
switch strings.ToLower(strings.TrimSpace(m.Kind + " " + m.Modality + " " + m.Type)) {
|
||||
case "image", "text-to-image", "image_generation", "text_to_image":
|
||||
return "image"
|
||||
}
|
||||
return "chat"
|
||||
}
|
||||
|
||||
// modalityList reads one array-of-strings field out of a raw JSON object,
|
||||
// tolerating both absence and a non-array value.
|
||||
func modalityList(raw json.RawMessage, field string) []string {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return nil
|
||||
}
|
||||
v, ok := obj[field]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal(v, &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// modelMeta carries through the fields an operator wants to see in the UI
|
||||
// without this code inventing them. The upstream nests most of them under
|
||||
// "metadata" (the real amr-link catalog does) while some put the same keys at
|
||||
// the top level, so both are read and top level wins on a tie. Only keys the
|
||||
// upstream actually sent are copied, so an absent context limit stays absent
|
||||
// instead of becoming 0.
|
||||
func modelMeta(m upstreamModel) map[string]interface{} {
|
||||
meta := map[string]interface{}{}
|
||||
|
||||
copyFields := func(raw json.RawMessage) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return
|
||||
}
|
||||
for _, k := range metadataFields {
|
||||
if v, ok := obj[k]; ok {
|
||||
if dec := rawOrString(v); dec != nil {
|
||||
meta[k] = dec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyFields(m.Metadata) // nested first
|
||||
copyFields(mustJSON(m)) // top level wins
|
||||
if len(meta) == 0 {
|
||||
return nil
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// rawOrString keeps numeric metadata as a number and everything else as its
|
||||
// decoded string, so the UI can sort on a cost or context length instead of
|
||||
// receiving "1048000" and having to coerce it.
|
||||
func rawOrString(raw json.RawMessage) interface{} {
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(raw, &num); err == nil {
|
||||
if f, err := num.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
var any interface{}
|
||||
if err := json.Unmarshal(raw, &any); err == nil {
|
||||
return any
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustJSON(v interface{}) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// modelsToConfig converts discovery output into config.Model entries, applying
|
||||
// the same priority convention the UI uses: chat models the operator is
|
||||
// likely to want first get a lower number, and image models sort last because
|
||||
// they are unreachable from /v1/chat/completions.
|
||||
func modelsToConfig(chat, image []string) []config.Model {
|
||||
out := make([]config.Model, 0, len(chat)+len(image))
|
||||
for _, id := range chat {
|
||||
out = append(out, config.Model{ID: id, Kind: "chat"})
|
||||
}
|
||||
for _, id := range image {
|
||||
out = append(out, config.Model{ID: id, Kind: "image"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -1,497 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"modelrouter/internal/config"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTP surface for model discovery.
|
||||
//
|
||||
// Two endpoints, deliberately separate:
|
||||
//
|
||||
// POST /api/sources/discover — probe a URL+key, return the catalog.
|
||||
// Nothing is written.
|
||||
// POST /api/sources/discover/apply — probe, then write the source.
|
||||
//
|
||||
// The split exists because discovery mutates nothing and is therefore safe to
|
||||
// call repeatedly while the operator is still typing a key, whereas apply
|
||||
// rewrites credentials and the AUTO chain's inputs. Making them one endpoint
|
||||
// with a flag would mean a "just check it" click can silently create a source.
|
||||
//
|
||||
// Both are admin-only, matching /api/sources: the request body carries an API
|
||||
// key, and a model catalog reveals which upstreams an operator uses.
|
||||
|
||||
// discoverRequest is the body of both endpoints.
|
||||
type discoverRequest struct {
|
||||
// BaseURL is what the operator pasted. It may or may not include a
|
||||
// version segment; discovery works that out rather than making the
|
||||
// caller know this gateway's path-composition rules.
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
|
||||
// Name is required for apply, ignored for discover. It defaults from the
|
||||
// base URL's host when omitted, because "https://api.vendor.com/v1" is a
|
||||
// better starting name than "new-source".
|
||||
Name string `json:"name"`
|
||||
|
||||
// Adapter is optional. Discovery assumes "openai" because that is what a
|
||||
// /v1/models catalog implies; a non-OpenAI upstream (Anthropic, Gemini)
|
||||
// has no such endpoint, so discovery will legitimately fail for those and
|
||||
// the operator configures them by hand.
|
||||
Adapter string `json:"adapter"`
|
||||
|
||||
// Temperature and MaxConcurrent are carried into the written source.
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
|
||||
// ReplaceModels drops the source's existing model list before applying the
|
||||
// discovered one. It defaults to true for a NEW source and is required to
|
||||
// be spelled out for an existing one, so a re-discovery cannot silently
|
||||
// delete hand-tuned priorities.
|
||||
ReplaceModels *bool `json:"replace_models,omitempty"`
|
||||
|
||||
// Select narrows what gets written. Empty means everything discovered.
|
||||
// Ids that are not in the catalog are an error rather than a silent skip:
|
||||
// a typo here would produce a source that 404s on every request.
|
||||
Include []string `json:"include,omitempty"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
|
||||
// ImageModels names ids that are **kind=image** but whose upstream catalog
|
||||
// does not say so.
|
||||
//
|
||||
// 为什么需要(真实缺口):discoveredModelKind 只看上游的 modality 字段,
|
||||
// 并且刻意**不从模型名推断**(那会把 "gpt-image-1" 这类同名聊天模型
|
||||
// 错归成生图,而错误的 kind 会毒死 AUTO 链)。
|
||||
// 但有些网关的 `/v1/models` 只返回 `{id,object,created,owned_by}` ——
|
||||
// 零 modality(实测:某聚合网关的 3 个 gpt-image-* 全部落成 chat,
|
||||
// 于是生图端点永远报 `no image source configured`)。
|
||||
//
|
||||
// 两条原则都不破:不猜(默认仍完全按元数据),但允许**显式声明**。
|
||||
// 只影响写入的 kind,不影响目录里有没有这个模型。
|
||||
ImageModels []string `json:"image_models,omitempty"`
|
||||
}
|
||||
|
||||
// discoverResponse is the JSON the WebUI binds to.
|
||||
type discoverResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
|
||||
// Normalized source fields, ready to be echoed back to /api/sources.
|
||||
BaseURL string `json:"base_url"`
|
||||
Adapter string `json:"adapter"`
|
||||
Models []string `json:"models"`
|
||||
ImageModels []string `json:"image_models"`
|
||||
|
||||
// Endpoint is set only when discovery had to move a version segment.
|
||||
Endpoint string `json:"endpoint"`
|
||||
// ImageEndpoint is required whenever base_url carries a version segment:
|
||||
// the gateway's default image path is the ABSOLUTE "/v1/images/generations"
|
||||
// while chat's default is the relative "/chat/completions", so the same
|
||||
// base_url that yields a working chat URL yields a 404 image URL.
|
||||
ImageEndpoint string `json:"image_endpoint"`
|
||||
|
||||
// Diagnostics for the operator.
|
||||
ModelsPath string `json:"models_path"`
|
||||
Tried []string `json:"tried"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
|
||||
// Set by apply only.
|
||||
SourceName string `json:"source_name,omitempty"`
|
||||
Applied bool `json:"applied,omitempty"`
|
||||
}
|
||||
|
||||
func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
apply := strings.HasSuffix(r.URL.Path, "/apply")
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
||||
"use POST with {\"base_url\":…,\"api_key\":…}")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "cannot read body")
|
||||
return
|
||||
}
|
||||
var req discoverRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.BaseURL = strings.TrimSpace(req.BaseURL)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
|
||||
// ★ 只给 `name`(或两者都给但 key 为空)⇒ **从已存源取 base_url 与凭据**。
|
||||
//
|
||||
// 为什么必须有这条路径(真实痛点):刷新一个**已存在**源的模型目录,此前
|
||||
// 必须让运维把明文 key 再粘一次 —— 而库里的 key 是 `enc:v1:` 密文,
|
||||
// 运维手里未必还有原文;WebUI 也不回显明文(这是正确的安全设计)。
|
||||
// 结果就是「想刷新目录 → 必须重新申请/粘贴一次 key」,很多人干脆不刷新,
|
||||
// 于是源的模型目录越放越旧(实例:某源的 3 个生图模型从未被登记)。
|
||||
//
|
||||
// 安全边界:① 已要求 admin 角色;② 只能探测**本网关已配置的**源,
|
||||
// 不能借此拿任意 URL + 任意 key 做探测(那样就成了 SSRF/密钥外带渠道);
|
||||
// ③ 响应不回显 key(只回目录)。
|
||||
if req.Name != "" {
|
||||
if old := g.sourceByName(req.Name); old != nil {
|
||||
if req.BaseURL == "" {
|
||||
req.BaseURL = old.BaseURL
|
||||
}
|
||||
if req.APIKey == "" {
|
||||
req.APIKey = old.APIKey
|
||||
}
|
||||
if req.Adapter == "" {
|
||||
req.Adapter = old.Adapter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.BaseURL == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||||
"base_url required (or pass name of an existing source to reuse its base_url and credential)")
|
||||
return
|
||||
}
|
||||
if req.Adapter == "" {
|
||||
req.Adapter = "openai"
|
||||
}
|
||||
|
||||
// Bounded so a hung upstream cannot pin the handler; discovery has its own
|
||||
// per-request timeout on top.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := DiscoverModels(ctx, req.BaseURL, req.APIKey)
|
||||
if err != nil {
|
||||
// A rejected key must not be reported as an upstream outage: the two
|
||||
// need opposite actions from the operator, and 502 sends them to the
|
||||
// wrong one.
|
||||
status := http.StatusBadGateway
|
||||
var f *discoveryFailure
|
||||
if errors.As(err, &f) {
|
||||
status = f.StatusOrDefault()
|
||||
}
|
||||
writeError(w, status, "discover_failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// ★ 显式生图声明:把 operator 点名的 id 从 chat 挪到 image。
|
||||
//
|
||||
// 放在 selectModels **之前**:后者按 include/exclude 裁剪两个列表,
|
||||
// 挪类必须在裁剪前完成,否则一个"只声明成生图、又被 include 选中"的
|
||||
// 模型会先在 chat 里被保留、再被这里挪走 —— 顺序反了会得到两份。
|
||||
//
|
||||
// 只认**目录里确实存在的** id:写错一个字不该静默造出一个不存在的模型
|
||||
// (那会让源在每次照片请求时 404),而是明确报错。
|
||||
if len(req.ImageModels) > 0 {
|
||||
have := map[string]bool{}
|
||||
for _, m := range res.Models {
|
||||
have[m] = true
|
||||
}
|
||||
for _, m := range res.ImageModels {
|
||||
have[m] = true
|
||||
}
|
||||
var missing []string
|
||||
force := map[string]bool{}
|
||||
for _, id := range req.ImageModels {
|
||||
if !have[id] {
|
||||
missing = append(missing, id)
|
||||
continue
|
||||
}
|
||||
force[id] = true
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||||
"image_models contains ids that are not in the upstream catalog: "+
|
||||
strings.Join(missing, ", "))
|
||||
return
|
||||
}
|
||||
var keepChat []string
|
||||
for _, m := range res.Models {
|
||||
if !force[m] {
|
||||
keepChat = append(keepChat, m)
|
||||
}
|
||||
}
|
||||
seenImg := map[string]bool{}
|
||||
var newImage []string
|
||||
for _, m := range res.ImageModels {
|
||||
seenImg[m] = true
|
||||
newImage = append(newImage, m)
|
||||
}
|
||||
for id := range force {
|
||||
if !seenImg[id] {
|
||||
newImage = append(newImage, id)
|
||||
}
|
||||
}
|
||||
res.Models = keepChat
|
||||
res.ImageModels = newImage
|
||||
}
|
||||
|
||||
chat, image, warns, err := selectModels(res.Models, res.ImageModels, req.Include, req.Exclude)
|
||||
if err != nil {
|
||||
// 400: the operator asked for a model the catalog does not have.
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := &discoverResponse{
|
||||
OK: true,
|
||||
BaseURL: res.BaseURL,
|
||||
Adapter: req.Adapter,
|
||||
Models: chat,
|
||||
ImageModels: image,
|
||||
Endpoint: res.Endpoint,
|
||||
ImageEndpoint: res.ImageEndpoint,
|
||||
ModelsPath: res.ModelsPath,
|
||||
Tried: res.Tried,
|
||||
LatencyMS: res.LatencyMS,
|
||||
Note: res.Note,
|
||||
Warnings: warns,
|
||||
}
|
||||
if len(chat) == 0 && len(image) == 0 {
|
||||
out.Warnings = append(out.Warnings,
|
||||
"every discovered model was excluded; applying this would create a source with no models")
|
||||
}
|
||||
|
||||
if !apply {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = defaultSourceName(req.BaseURL)
|
||||
}
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||||
"name required: cannot derive one from base_url")
|
||||
return
|
||||
}
|
||||
if strings.ContainsAny(name, "/\\ \t") || name == "." || name == ".." {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||||
"source name must not contain slashes or whitespace")
|
||||
return
|
||||
}
|
||||
|
||||
exists := g.sourceExists(name)
|
||||
if exists && req.ReplaceModels == nil {
|
||||
// Refusing here is deliberate. Re-running discovery on an existing
|
||||
// source and having it silently replace a hand-tuned model list —
|
||||
// including priorities chosen for the AUTO chain — is the kind of
|
||||
// "helpful" behaviour that loses an afternoon.
|
||||
writeError(w, http.StatusConflict, "conflict",
|
||||
"source "+name+" already exists; set replace_models=true to overwrite its model list, "+
|
||||
"or choose a different name")
|
||||
return
|
||||
}
|
||||
|
||||
// Build the source directly rather than round-tripping through the
|
||||
// /api/sources payload: resolveAPIKey carries the "an empty api_key means
|
||||
// keep the stored one" rule, and going through a second decoder here would
|
||||
// duplicate (and eventually diverge from) that logic.
|
||||
src := config.Source{
|
||||
Name: name,
|
||||
BaseURL: res.BaseURL,
|
||||
APIKey: g.resolveAPIKey(name, req.APIKey, exists),
|
||||
Adapter: req.Adapter,
|
||||
Endpoint: res.Endpoint,
|
||||
ImageEndpoint: res.ImageEndpoint,
|
||||
Models: modelsToConfig(chat, image),
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
if req.MaxConcurrent > 0 {
|
||||
src.MaxConcurrent = req.MaxConcurrent
|
||||
}
|
||||
|
||||
// An existing source may carry fields discovery does not know about
|
||||
// (proxy_url, headers, api_key_env, timeout). Rebuilding from scratch would
|
||||
// drop them, so they are inherited unless the request overrides them.
|
||||
if exists {
|
||||
old := g.sourceByName(name)
|
||||
if old != nil {
|
||||
src.ProxyURL = old.ProxyURL
|
||||
src.APIKeyEnv = old.APIKeyEnv
|
||||
src.Timeout = old.Timeout
|
||||
src.QueueTimeout = old.QueueTimeout
|
||||
src.Headers = old.Headers
|
||||
src.Meta = old.Meta
|
||||
src.MaxTokens = old.MaxTokens
|
||||
src.RPM = old.RPM
|
||||
// image_endpoint is NOT inherited when discovery supplied one:
|
||||
// the stored value was correct for the OLD base_url, and the base
|
||||
// is exactly what may have changed. Inheriting it would keep a
|
||||
// stale value that 404s against the new base.
|
||||
if res.ImageEndpoint == "" {
|
||||
src.ImageEndpoint = old.ImageEndpoint
|
||||
}
|
||||
if src.Adapter == "" {
|
||||
src.Adapter = old.Adapter
|
||||
}
|
||||
if req.MaxConcurrent <= 0 {
|
||||
src.MaxConcurrent = old.MaxConcurrent
|
||||
}
|
||||
// replace_models=false keeps the curated list. Discovery still
|
||||
// refreshed base_url/endpoint/credential, which is the point of
|
||||
// re-running it.
|
||||
if req.ReplaceModels != nil && !*req.ReplaceModels {
|
||||
src.Models = old.Models
|
||||
out.Warnings = append(out.Warnings,
|
||||
"model list left unchanged (replace_models=false)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := g.core.AddSource(src); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out.SourceName = name
|
||||
out.Applied = true
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// selectModels applies the include/exclude filters.
|
||||
//
|
||||
// An include entry the catalog does not have is an error: silently dropping it
|
||||
// produces a source that looks configured and 404s at request time, which is
|
||||
// far harder to diagnose than a rejected apply. An exclude entry that is not
|
||||
// present is fine — the operator is allowed to be defensive.
|
||||
func selectModels(chat, image, include, exclude []string) ([]string, []string, []string, error) {
|
||||
have := map[string]bool{}
|
||||
for _, m := range chat {
|
||||
have[m] = true
|
||||
}
|
||||
for _, m := range image {
|
||||
have[m] = true
|
||||
}
|
||||
|
||||
if len(include) > 0 {
|
||||
var missing []string
|
||||
for _, want := range include {
|
||||
if !have[want] {
|
||||
missing = append(missing, want)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, nil, nil, &discoverError{
|
||||
msg: "these models are not in the upstream catalog: " + strings.Join(missing, ", "),
|
||||
}
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, want := range include {
|
||||
keep[want] = true
|
||||
}
|
||||
var c, i []string
|
||||
for _, m := range chat {
|
||||
if keep[m] {
|
||||
c = append(c, m)
|
||||
}
|
||||
}
|
||||
for _, m := range image {
|
||||
if keep[m] {
|
||||
i = append(i, m)
|
||||
}
|
||||
}
|
||||
chat, image = c, i
|
||||
}
|
||||
|
||||
if len(exclude) > 0 {
|
||||
drop := map[string]bool{}
|
||||
for _, m := range exclude {
|
||||
drop[m] = true
|
||||
}
|
||||
var c, i []string
|
||||
for _, m := range chat {
|
||||
if !drop[m] {
|
||||
c = append(c, m)
|
||||
}
|
||||
}
|
||||
for _, m := range image {
|
||||
if !drop[m] {
|
||||
i = append(i, m)
|
||||
}
|
||||
}
|
||||
chat, image = c, i
|
||||
}
|
||||
|
||||
// chat and image must stay non-nil: the JSON contract forbids null here.
|
||||
if chat == nil {
|
||||
chat = []string{}
|
||||
}
|
||||
if image == nil {
|
||||
image = []string{}
|
||||
}
|
||||
|
||||
var warns []string
|
||||
if len(chat) == 0 && len(image) == 0 {
|
||||
// Caller can only get here by excluding everything, or from a catalog
|
||||
// that was already empty. Either way "silence" would read as success.
|
||||
warns = append(warns,
|
||||
"no models selected; applying this would create a source that cannot serve any request")
|
||||
return chat, image, warns, nil
|
||||
}
|
||||
if len(image) > 0 && len(chat) == 0 {
|
||||
warns = append(warns,
|
||||
"every model is an image model; AUTO chat requests have nothing to select")
|
||||
}
|
||||
if len(chat) > 0 && len(image) > 0 {
|
||||
warns = append(warns,
|
||||
"image models are only reachable through /v1/images/generations, not AUTO chat")
|
||||
}
|
||||
return chat, image, warns, nil
|
||||
}
|
||||
|
||||
// discoverError is a client mistake rather than an upstream failure, which is
|
||||
// why it gets its own type instead of fmt.Errorf.
|
||||
type discoverError struct{ msg string }
|
||||
|
||||
func (e *discoverError) Error() string { return e.msg }
|
||||
|
||||
// defaultSourceName derives a source name from a base URL host, stripping the
|
||||
// version segment and any "api." prefix. It returns "" for input that is not a
|
||||
// URL at all — the caller must not fall back to echoing garbage into a source
|
||||
// name, since the name becomes a config key and an API path segment.
|
||||
func defaultSourceName(baseURL string) string {
|
||||
s := strings.TrimSpace(baseURL)
|
||||
if !strings.Contains(s, "://") {
|
||||
return ""
|
||||
}
|
||||
s = s[strings.Index(s, "://")+3:]
|
||||
if i := strings.IndexAny(s, "/?#"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.LastIndex(s, ":"); i > 0 && !strings.Contains(s[i+1:], "]") {
|
||||
s = s[:i] // strip port
|
||||
}
|
||||
// A host must still look like one after trimming the cosmetic prefixes.
|
||||
s = strings.TrimPrefix(s, "api.")
|
||||
s = strings.TrimPrefix(s, "www.")
|
||||
if s == "" || strings.ContainsAny(s, " \t\"'<>") {
|
||||
return ""
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == '.' || r == '-' || r == '_':
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s, ".") {
|
||||
// A bare label is far more likely to be a mistake than a hostname.
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -1,314 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// upstream that answers /v1/models with the real captured catalog
|
||||
func catalogUpstream(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer sk-secret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(realCatalogBody))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestDefaultSourceName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://api.vendor.com/v1": "vendor.com",
|
||||
"https://api.openai.com/v1": "openai.com",
|
||||
"https://www.vendor.com/v1": "vendor.com",
|
||||
"http://127.0.0.1:8080/v1": "127.0.0.1",
|
||||
"https://amr-link.open-design.ai/v1": "amr-link.open-design.ai",
|
||||
"not a url at all": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := defaultSourceName(in); got != want {
|
||||
t.Errorf("defaultSourceName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectModels_IncludeUnknownIsAnError(t *testing.T) {
|
||||
chat := []string{"a", "b"}
|
||||
image := []string{"img"}
|
||||
// Silently dropping a typo'd id would create a source that looks
|
||||
// configured and 404s on every request — much worse than a rejection.
|
||||
_, _, _, err := selectModels(chat, image, []string{"a", "typo"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an include id that is not in the catalog")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "typo") {
|
||||
t.Errorf("error should name the offending id, got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectModels_IncludeNarrows(t *testing.T) {
|
||||
chat, image, _, err := selectModels([]string{"a", "b", "c"}, []string{"img1", "img2"}, []string{"b", "img1"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chat) != 1 || chat[0] != "b" {
|
||||
t.Errorf("chat = %v, want [b]", chat)
|
||||
}
|
||||
if len(image) != 1 || image[0] != "img1" {
|
||||
t.Errorf("image = %v, want [img1]", image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectModels_ExcludeUnknownIsFine(t *testing.T) {
|
||||
chat, _, _, err := selectModels([]string{"a"}, nil, nil, []string{"never-existed"})
|
||||
if err != nil {
|
||||
t.Errorf("excluding a model that is not present should be allowed: %v", err)
|
||||
}
|
||||
if len(chat) != 1 {
|
||||
t.Errorf("chat = %v, want [a] unchanged", chat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectModels_NeverReturnsNil(t *testing.T) {
|
||||
chat, image, warns, err := selectModels(nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chat == nil || image == nil {
|
||||
t.Fatal("nil slices would serialize as JSON null and break the UI")
|
||||
}
|
||||
if len(warns) == 0 {
|
||||
t.Error("an empty catalog deserves a warning, not silence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectModels_WarnsAboutImageOnlyCatalog(t *testing.T) {
|
||||
_, _, warns, err := selectModels(nil, []string{"gpt-image-2"}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(warns, " ")
|
||||
if !strings.Contains(joined, "AUTO") {
|
||||
t.Errorf("warnings %v should say AUTO has nothing to select", warns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectModels_WarnsThatImagesAreNotInAUTO(t *testing.T) {
|
||||
_, _, warns, err := selectModels([]string{"a"}, []string{"img"}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(warns, " "), "images/generations") {
|
||||
t.Errorf("warnings %v should point at the image endpoint", warns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryResultAndConfigRoundTrip checks the fields the WebUI will echo
|
||||
// back actually survive into a config.Source — a mismatch here means the
|
||||
// operator previews one set of values and saves another.
|
||||
func TestDiscoveryResultAndConfigRoundTrip(t *testing.T) {
|
||||
res := &discoveryResult{
|
||||
BaseURL: "https://h",
|
||||
Endpoint: "/v1/chat/completions",
|
||||
Models: []string{"m1", "m2"},
|
||||
ImageModels: []string{"img1"},
|
||||
}
|
||||
src := config.Source{
|
||||
Name: "x",
|
||||
BaseURL: res.BaseURL,
|
||||
Endpoint: res.Endpoint,
|
||||
Models: modelsToConfig(res.Models, res.ImageModels),
|
||||
}
|
||||
if len(src.Models) != 3 {
|
||||
t.Fatalf("models = %d, want 3", len(src.Models))
|
||||
}
|
||||
if src.Endpoint != "/v1/chat/completions" {
|
||||
t.Errorf("endpoint lost: %q", src.Endpoint)
|
||||
}
|
||||
var img int
|
||||
for _, m := range src.Models {
|
||||
if m.Kind == "image" {
|
||||
img++
|
||||
}
|
||||
}
|
||||
if img != 1 {
|
||||
t.Errorf("image models = %d, want 1", img)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_SendsAuthorizationHeader pins the only way the feature
|
||||
// works against a real vendor: without the bearer token every paid /models
|
||||
// endpoint returns 401.
|
||||
func TestDiscoverModels_SendsAuthorizationHeader(t *testing.T) {
|
||||
var seen string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r.Header.Get("Authorization")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != "Bearer sk-secret" {
|
||||
t.Errorf("Authorization = %q, want %q", seen, "Bearer sk-secret")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway pins the distinction
|
||||
// that decides whether the operator knows what to do next.
|
||||
//
|
||||
// A rejected key and an unreachable vendor are the two most common discovery
|
||||
// failures, and they need opposite actions: re-type the key, versus go check the
|
||||
// vendor's status page. Both used to surface as one generic error, so the
|
||||
// operator had to guess.
|
||||
func TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"invalid api key","code":"invalid_api_key"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-wrong")
|
||||
if err == nil {
|
||||
t.Fatal("expected a failure against a 401 endpoint")
|
||||
}
|
||||
var f *discoveryFailure
|
||||
if !errors.As(err, &f) {
|
||||
t.Fatalf("error should be classified, got %T: %v", err, err)
|
||||
}
|
||||
if got := f.StatusOrDefault(); got != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d — a wrong key must not read as an upstream outage",
|
||||
got, http.StatusUnauthorized)
|
||||
}
|
||||
// The message has to name which credential is at fault, or the status code
|
||||
// alone still sends the operator to the wrong place.
|
||||
if !strings.Contains(strings.ToLower(f.Error()), "api key") {
|
||||
t.Errorf("message %q should name the api key as the cause", f.Error())
|
||||
}
|
||||
if !strings.Contains(f.Error(), "/v1/models") {
|
||||
t.Errorf("message %q should list what was tried", f.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_UnreachableStaysBadGateway: the classification must not
|
||||
// over-correct — an unresolvable host is still the vendor's or the network's
|
||||
// problem, not the key's.
|
||||
func TestDiscoverModels_UnreachableStaysBadGateway(t *testing.T) {
|
||||
_, err := DiscoverModels(context.Background(), "https://no-such-host-xyz.invalid/v1", "k")
|
||||
if err == nil {
|
||||
t.Fatal("expected a failure for an unresolvable host")
|
||||
}
|
||||
var f *discoveryFailure
|
||||
if !errors.As(err, &f) {
|
||||
t.Fatalf("error should be classified, got %T", err)
|
||||
}
|
||||
if got := f.StatusOrDefault(); got != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want %d", got, http.StatusBadGateway)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(f.Error()), "resolve") {
|
||||
t.Errorf("message %q should say the hostname does not resolve", f.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryCandidates_NoDuplicateRequests: base "https://h/v1" with path
|
||||
// "/models" and base "https://h" with path "/v1/models" request the SAME URL.
|
||||
// Listing it twice in a failure message reads as "two things were tried" when
|
||||
// only one was.
|
||||
func TestDiscoveryCandidates_NoDuplicateRequests(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, c := range discoveryCandidates("https://h/v1") {
|
||||
url := c.baseURL + c.modelsPath
|
||||
if seen[url] {
|
||||
t.Errorf("candidate %q would be requested twice", url)
|
||||
}
|
||||
seen[url] = true
|
||||
}
|
||||
if len(seen) < 2 {
|
||||
t.Errorf("only %d distinct URLs; the search collapsed", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_FailureListsEachURLOnce is the end-to-end form of the same
|
||||
// rule, on the error text an operator actually reads.
|
||||
func TestDiscoverModels_FailureListsEachURLOnce(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
|
||||
if err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
msg := err.Error()
|
||||
needle := srv.URL + "/v1/models"
|
||||
if n := strings.Count(msg, needle); n > 1 {
|
||||
t.Errorf("URL %s appears %d times in %q", needle, n, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverModels_EmptyKeyOmitsHeader(t *testing.T) {
|
||||
var present bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, present = r.Header["Authorization"]
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if present {
|
||||
t.Error("an empty key should omit the header, not send an empty bearer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_RealCatalogEndToEnd walks the actual captured response
|
||||
// through the whole path and asserts the numbers an operator would see.
|
||||
func TestDiscoverModels_RealCatalogEndToEnd(t *testing.T) {
|
||||
srv := catalogUpstream(t)
|
||||
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("discovery failed: %v", err)
|
||||
}
|
||||
if res.BaseURL != srv.URL+"/v1" {
|
||||
t.Errorf("base = %q, want the typed URL unchanged", res.BaseURL)
|
||||
}
|
||||
if res.Endpoint != "" {
|
||||
t.Errorf("endpoint = %q, want empty (base already carries /v1)", res.Endpoint)
|
||||
}
|
||||
if len(res.Models) != 2 || len(res.ImageModels) != 2 {
|
||||
t.Errorf("chat/image = %d/%d, want 2/2", len(res.Models), len(res.ImageModels))
|
||||
}
|
||||
if res.Adapter != "openai" {
|
||||
t.Errorf("adapter = %q, want openai", res.Adapter)
|
||||
}
|
||||
|
||||
// The response must be JSON the UI can consume directly.
|
||||
b, _ := json.Marshal(res)
|
||||
var round map[string]interface{}
|
||||
if err := json.Unmarshal(b, &round); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if round["ok"] != nil {
|
||||
// discoverResponse adds ok; discoveryResult does not. Both marshal
|
||||
// fine, so this is only a shape note.
|
||||
_ = round
|
||||
}
|
||||
}
|
||||
@ -1,153 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestComposeImageEndpoint_NoVersionBaseLeavesDefault is the counterpart of the
|
||||
// /v1 case: when the base carries no version segment, discovery must emit "" so
|
||||
// the gateway's own default "/v1/images/generations" composes.
|
||||
//
|
||||
// This is the shape every currently-working source uses — siliconflow, the only
|
||||
// source in production with kind:image, has base_url
|
||||
// "https://api.siliconflow.cn" — so writing a non-empty value here would break
|
||||
// exactly the configuration that is known to work today.
|
||||
func TestComposeImageEndpoint_NoVersionBaseLeavesDefault(t *testing.T) {
|
||||
if got := composeImageEndpoint("https://api.siliconflow.cn"); got != "" {
|
||||
t.Errorf("composeImageEndpoint = %q, want \"\" so the built-in default composes", got)
|
||||
}
|
||||
// And the composition it implies must be the vendor's real path.
|
||||
base := "https://api.siliconflow.cn"
|
||||
composed := base + "/v1/images/generations"
|
||||
if !strings.HasSuffix(composed, "/v1/images/generations") {
|
||||
t.Errorf("composed URL %q lost the image path", composed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned
|
||||
// distinguishes two cases that look identical from the outside.
|
||||
//
|
||||
// bare host + catalog at "/models" → chat is "/chat/completions" (the
|
||||
// adapter default, no endpoint written)
|
||||
// bare host + catalog at "/v1/models" → chat is "/v1/chat/completions",
|
||||
// so an endpoint MUST be written
|
||||
//
|
||||
// The catalog path is the only evidence of whether the upstream wants the
|
||||
// version segment, and getting this backwards sends every chat request to a
|
||||
// path the vendor does not serve.
|
||||
func TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
catalogPath string
|
||||
wantEndpoint string
|
||||
wantImage string
|
||||
}{
|
||||
{
|
||||
name: "catalog at the bare /models needs no endpoint",
|
||||
catalogPath: "/models",
|
||||
wantEndpoint: "",
|
||||
wantImage: "",
|
||||
},
|
||||
{
|
||||
name: "catalog at /v1/models requires /v1 on the chat endpoint",
|
||||
catalogPath: "/v1/models",
|
||||
wantEndpoint: "/v1/chat/completions",
|
||||
wantImage: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != tc.catalogPath {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := DiscoverModels(context.Background(), srv.URL, "k")
|
||||
if err != nil {
|
||||
t.Fatalf("discovery failed: %v", err)
|
||||
}
|
||||
if res.BaseURL != srv.URL {
|
||||
t.Errorf("base = %q, want %q", res.BaseURL, srv.URL)
|
||||
}
|
||||
if res.Endpoint != tc.wantEndpoint {
|
||||
t.Errorf("endpoint = %q, want %q", res.Endpoint, tc.wantEndpoint)
|
||||
}
|
||||
if res.ImageEndpoint != tc.wantImage {
|
||||
t.Errorf("image_endpoint = %q, want %q", res.ImageEndpoint, tc.wantImage)
|
||||
}
|
||||
// Whatever it wrote, the chat URL must reach the same version
|
||||
// prefix the catalog was served under.
|
||||
ep := res.Endpoint
|
||||
if ep == "" {
|
||||
ep = "/chat/completions"
|
||||
}
|
||||
if got := res.BaseURL + ep; strings.Contains(got, "/v1/v1") {
|
||||
t.Errorf("chat URL %q has a doubled version", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposeEndpoints_BothFormsProduceWorkingURLs is the end-to-end
|
||||
// invariant across the two shapes, stated as the URLs the gateway will build.
|
||||
//
|
||||
// A base with a version and no image_endpoint produces "/v1/v1/…" — the exact
|
||||
// 404 observed before composeImageEndpoint existed. A base without one and a
|
||||
// non-empty image_endpoint produces a path missing the version. Both are
|
||||
// silent, expensive mistakes, so they are asserted literally.
|
||||
func TestComposeEndpoints_BothFormsProduceWorkingURLs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
wantChat string
|
||||
wantImage string
|
||||
}{
|
||||
{
|
||||
name: "bare host: adapter and image defaults compose",
|
||||
base: "https://h",
|
||||
wantChat: "https://h/chat/completions",
|
||||
// openai's adapter endpoint is /chat/completions (verified in
|
||||
// internal/lua/adapters/openai.lua: adapter.endpoint).
|
||||
wantImage: "https://h/v1/images/generations",
|
||||
},
|
||||
{
|
||||
name: "versioned base: the version moves onto both endpoints",
|
||||
base: "https://h/v1",
|
||||
wantChat: "https://h/v1/chat/completions",
|
||||
wantImage: "https://h/v1/images/generations",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ep := composeEndpoint("/models", tc.base, tc.base)
|
||||
if ep == "" {
|
||||
ep = "/chat/completions" // the adapter default
|
||||
}
|
||||
iep := composeImageEndpoint(tc.base)
|
||||
if iep == "" {
|
||||
iep = "/v1/images/generations" // the gateway default
|
||||
}
|
||||
gotChat := tc.base + ep
|
||||
gotImage := tc.base + iep
|
||||
if gotChat != tc.wantChat {
|
||||
t.Errorf("chat URL = %q, want %q", gotChat, tc.wantChat)
|
||||
}
|
||||
if gotImage != tc.wantImage {
|
||||
t.Errorf("image URL = %q, want %q", gotImage, tc.wantImage)
|
||||
}
|
||||
for _, u := range []string{gotChat, gotImage} {
|
||||
if strings.Contains(u, "/v1/v1") {
|
||||
t.Errorf("%q has a doubled version segment", u)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,347 +0,0 @@
|
||||
package gateway
|
||||
|
||||
// Real mutation injection: rewrites the IMPLEMENTATION on disk, re-runs the
|
||||
// suite, and requires it to go red.
|
||||
//
|
||||
// Why this exists and why it is a Go file rather than a shell script:
|
||||
//
|
||||
// The first version was a shell script. Its -run filter contained a regex that
|
||||
// the shell mangled, so every mutant reported "SURVIVED" while go test printed
|
||||
// "[no tests to run]". Four mutants looked like judgement failures and were
|
||||
// actually never executed. A judgement that cannot tell "tests passed" from
|
||||
// "no tests ran" is not a judgement.
|
||||
//
|
||||
// This version therefore refuses to report anything unless it has first proved
|
||||
// the filter selects a non-trivial number of tests (see baseline below), and it
|
||||
// treats "[no tests to run]" as a hard error rather than a green run.
|
||||
//
|
||||
// It also must not be run against a DIRTY tree: the mutant is written over
|
||||
// discover.go, and a leftover mutant from a killed run would make the baseline
|
||||
// green against broken code while every verdict came from the wrong source.
|
||||
// See the guard at the top of TestRealMutants.
|
||||
//
|
||||
// Run: go test -tags luajit ./internal/gateway/ -run TestRealMutants -v
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mutationFilter must select every assertion that guards the code below. It is
|
||||
// deliberately broad: a narrow filter is how mutants get to survive for free.
|
||||
const mutationFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|ComposeImageEndpoint|ComposeEndpoints|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates|Discovery_BareHost)"
|
||||
|
||||
type mutation struct {
|
||||
name string
|
||||
from string
|
||||
to string
|
||||
}
|
||||
|
||||
func TestRealMutants(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("mutation campaign compiles the package repeatedly")
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(wd, "discover.go")
|
||||
orig, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Refuse to run against a dirty tree.
|
||||
//
|
||||
// The mutant is written OVER discover.go, so on a dirty file the "original"
|
||||
// captured above is not the committed source — the baseline then runs against
|
||||
// whatever was there, and a killed run (a background-task timeout, a Ctrl-C)
|
||||
// leaves a mutant behind that the NEXT run treats as the baseline. Both make
|
||||
// every verdict meaningless while the harness still reports a clean score.
|
||||
//
|
||||
// Refusing rather than restoring: this cannot tell "leftover mutant" apart from
|
||||
// "the developer is mid-edit", and silently reverting real work is worse than
|
||||
// stopping.
|
||||
if dirty, derr := mutationTargetIsDirty(wd, "discover.go"); derr == nil && dirty {
|
||||
t.Fatalf("%s has uncommitted changes — commit or stash them first. A mutant "+
|
||||
"left behind by a killed run would otherwise become the baseline, and "+
|
||||
"every verdict below would be about code nobody wrote.", target)
|
||||
}
|
||||
defer func() {
|
||||
if werr := os.WriteFile(target, orig, 0o644); werr != nil {
|
||||
t.Fatalf("restore discover.go: %v", werr)
|
||||
}
|
||||
}()
|
||||
|
||||
// --- prove the harness is armed before trusting any verdict ---
|
||||
// A filter that selects nothing exits 0, which is indistinguishable from a
|
||||
// real pass. The only way to tell them apart is to compare against a filter
|
||||
// KNOWN to be empty: if that empty filter produces the same result as the
|
||||
// real one, the real one is probably empty too.
|
||||
//
|
||||
// Note go test does NOT print "[no tests to run]" for a wholly unmatched
|
||||
// filter (it prints that only when the package has other matching tests), so
|
||||
// the check has to compare outputs rather than look for a marker string.
|
||||
// Getting this backwards made the harness refuse to run at all.
|
||||
emptyOut := runSuite(wd, "TestZZZNoSuchTestExists")
|
||||
realOut := runSuite(wd, mutationFilter)
|
||||
if countTests(emptyOut) != 0 {
|
||||
t.Fatalf("a filter matching nothing selected %d tests; the harness's own probe is broken",
|
||||
countTests(emptyOut))
|
||||
}
|
||||
// Kept for the no-op check below: a mutant whose output matches the
|
||||
// baseline never changed behaviour, so it is an invalid mutation rather
|
||||
// than a gap in the tests.
|
||||
baseOut := realOut
|
||||
if strings.Contains(realOut, "[no tests to run]") {
|
||||
t.Fatalf("baseline selected no tests:\n%s", realOut)
|
||||
}
|
||||
baseCount := countTests(realOut)
|
||||
if baseCount < 25 {
|
||||
t.Fatalf("baseline ran only %d tests; the filter is too narrow to judge anything", baseCount)
|
||||
}
|
||||
if strings.Contains(realOut, "FAIL") {
|
||||
t.Logf("note: baseline is not green; kill verdicts remain valid")
|
||||
}
|
||||
t.Logf("baseline: %d tests selected, empty filter selects %d",
|
||||
baseCount, countTests(emptyOut))
|
||||
|
||||
muts := []mutation{
|
||||
{
|
||||
// Written as a condition that still READS modOut, because a
|
||||
// mutation that leaves a variable unused fails to compile — and a
|
||||
// build failure is not a judgement (see the harness note above).
|
||||
name: "image kind ignores upstream output modality",
|
||||
from: `if m.Capabilities.ImageGeneration || has(modOut, "image") {`,
|
||||
to: "if m.Capabilities.ImageGeneration || (len(modOut) > 0 && false) {",
|
||||
},
|
||||
{
|
||||
name: "kind defaults to image (guessing from names)",
|
||||
from: "\t}\n\treturn \"chat\"\n}\n\n// modalityList",
|
||||
to: "\t}\n\treturn \"image\"\n}\n\n// modalityList",
|
||||
},
|
||||
{
|
||||
name: "nested metadata no longer read",
|
||||
// 锚点必须与 discover.go 逐字节一致:此前这里是 6 个空格,源码已
|
||||
// 变成 2 个空格,于是这条变异永远"找不到锚点"——变异campaign 每次
|
||||
// 都以 anchor not found 失败,而它本该验证"嵌套 metadata 被丢掉"能被
|
||||
// 测试抓住。锚点漂移 = 这条变异从未真正跑过(和 issue #1 里
|
||||
// `cc["models"]` 恒 false 的测试跳过是同一种失效:断言写了但没执行)。
|
||||
from: "\tcopyFields(m.Metadata) // nested first",
|
||||
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
|
||||
},
|
||||
{
|
||||
name: "non-catalog bodies accepted",
|
||||
from: "\tif trimmed == \"\" || trimmed == \"null\" {\n\t\treturn nil, fmt.Errorf(\"empty response\")\n\t}",
|
||||
to: "\tif false && (trimmed == \"\" || trimmed == \"null\") {\n\t\treturn nil, fmt.Errorf(\"empty response\")\n\t}",
|
||||
},
|
||||
{
|
||||
// Removing the bare "/models" candidate must be observable: a vendor
|
||||
// serving its catalog at the bare path would otherwise look
|
||||
// unreachable. The `len(...) >= 0` is a tautology that only exists to
|
||||
// keep the expression syntactically valid after the call is dropped.
|
||||
name: "URL fallback search removed",
|
||||
from: "\t\tadd(raw, \"/models\", 0)",
|
||||
to: "\t\tif len(\"/models\") >= 0 {\n\t\t\t_ = hasVersion\n\t\t}",
|
||||
},
|
||||
{
|
||||
name: "version segment never moved onto the chat endpoint",
|
||||
from: "\tif version == \"\" {\n\t\treturn \"\" // adapter default already composes\n\t}",
|
||||
to: "\tif version == \"\" || len(version) > 0 {\n\t\treturn \"\"\n\t}",
|
||||
},
|
||||
{
|
||||
name: "image endpoint never corrected (/v1/v1 regression)",
|
||||
from: "\tif b := trimVersionSuffix(persistedBase); b != persistedBase {\n\t\treturn \"/images/generations\"\n\t}",
|
||||
to: "\tif b := trimVersionSuffix(persistedBase); b == b && b != b {\n\t\treturn \"/images/generations\"\n\t}",
|
||||
},
|
||||
{
|
||||
name: "version test loosened so 'vision' counts as a version",
|
||||
from: "\tsuffix := strings.ToLower(seg[i:])",
|
||||
to: "\tsuffix := strings.ToLower(seg[i:])\n\tif len(suffix) >= 0 {\n\t\tsuffix = \"beta\"\n\t}",
|
||||
},
|
||||
{
|
||||
// The guard must be removable WITHOUT the mutant becoming a
|
||||
// semantic no-op. Two earlier attempts failed:
|
||||
//
|
||||
// `&& out.BaseURL != ""` — the guard this test exercises uses a
|
||||
// zero-value struct whose BaseURL is empty, so the added
|
||||
// condition made the mutant behave identically to the original.
|
||||
// `&& len(out.BaseURL) >= 0` — always true for any string, so the
|
||||
// mutant was STILL an identity transform.
|
||||
//
|
||||
// Reading the variable into a blank assignment keeps the compiler
|
||||
// happy without changing behaviour, which is what makes the
|
||||
// mutant observable.
|
||||
name: "nil slice guard removed from JSON output",
|
||||
from: "\tif out.ImageModels == nil {\n\t\tout.ImageModels = []string{}\n\t}",
|
||||
to: "\tif out.ImageModels == nil {\n\t\t_ = out.ImageModels\n\t}",
|
||||
},
|
||||
{
|
||||
name: "auth rejection no longer classified",
|
||||
from: "\tif status != http.StatusUnauthorized && status != http.StatusForbidden {\n\t\treturn \"\"\n\t}",
|
||||
to: "\tif status != http.StatusUnauthorized && status != http.StatusForbidden || len(body) >= 0 {\n\t\treturn \"\"\n\t}",
|
||||
},
|
||||
{
|
||||
name: "duplicate candidate URLs no longer collapsed",
|
||||
from: "\t\tif seenPair[pair] || seenURL[base+path] {",
|
||||
to: "\t\tif len(pair) > 0 && (seenPair[pair] || seenURL[base+path]) && false {",
|
||||
},
|
||||
}
|
||||
|
||||
survived := 0
|
||||
for _, m := range muts {
|
||||
t.Run(m.name, func(t *testing.T) {
|
||||
if !strings.Contains(string(orig), m.from) {
|
||||
t.Fatalf("anchor not found in discover.go — this mutation would be a no-op:\n%q", m.from)
|
||||
}
|
||||
mutated := strings.Replace(string(orig), m.from, m.to, 1)
|
||||
if mutated == string(orig) {
|
||||
t.Fatal("replacement changed nothing")
|
||||
}
|
||||
if werr := os.WriteFile(target, []byte(mutated), 0o644); werr != nil {
|
||||
t.Fatal(werr)
|
||||
}
|
||||
defer os.WriteFile(target, orig, 0o644)
|
||||
|
||||
out := runSuite(wd, mutationFilter)
|
||||
// A build failure kills EVERY mutant, so it would mask a broken
|
||||
// harness as a perfect score. Distinguish it from a real test
|
||||
// failure and fail loudly instead.
|
||||
if strings.Contains(out, "[build failed]") {
|
||||
t.Fatalf("mutant broke the build — that is not a judgement, it is a broken harness:\n%s", firstLines(out, 20))
|
||||
}
|
||||
if countTests(out) == 0 {
|
||||
t.Fatalf("filter selected nothing — the verdict would be meaningless")
|
||||
}
|
||||
if !strings.Contains(out, "FAIL") {
|
||||
// The mutant compiled and the suite stayed green. Before calling
|
||||
// this a survivor, check the mutation was not a no-op: an earlier
|
||||
// version reported four "survivors" that were really invalid
|
||||
// mutations whose added guard made them behaviourally identical
|
||||
// to the original. A silent no-op is a broken mutation, and
|
||||
// conflating the two sends the next reader hunting for a missing
|
||||
// test that does not exist.
|
||||
if behaviourOnly(out) == behaviourOnly(baseOut) {
|
||||
t.Fatalf("mutant is a no-op: the suite output is identical to the "+
|
||||
"baseline, so this mutation never changed behaviour. Fix the "+
|
||||
"mutation rather than looking for a missing test.\nmutant: %q", m.to)
|
||||
}
|
||||
survived++
|
||||
t.Errorf("SURVIVED %s — no test noticed this wrong implementation", m.name)
|
||||
return
|
||||
}
|
||||
t.Logf("KILL %s", m.name)
|
||||
})
|
||||
}
|
||||
if survived > 0 {
|
||||
t.Errorf("%d of %d mutants survived", survived, len(muts))
|
||||
}
|
||||
}
|
||||
|
||||
// runSuite invokes the go tool on this package with the given -run filter.
|
||||
//
|
||||
// The luajit build tag is mandatory: without it the package falls back to plain
|
||||
// Lua 5.1, billing.lua's `goto` fails to compile, and the whole package fails
|
||||
// to build — which the harness would misread as "every mutant killed".
|
||||
func runSuite(wd, filter string) string {
|
||||
tool := os.Getenv("GO_TEST_GO")
|
||||
if tool == "" {
|
||||
tool = "go"
|
||||
}
|
||||
cmd := exec.Command(tool, "test",
|
||||
"-tags", "luajit",
|
||||
".", "-run", filter, "-count=1", "-v")
|
||||
cmd.Dir = wd
|
||||
cmd.Env = os.Environ()
|
||||
out, _ := cmd.CombinedOutput()
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// mutationTargetIsDirty reports whether a file under internal/gateway has
|
||||
// uncommitted changes, so the harness can refuse to run instead of treating a
|
||||
// leftover mutant as the baseline.
|
||||
//
|
||||
// git runs from the package directory with a PATH RELATIVE to it. An earlier
|
||||
// version of the sibling script passed a repo-relative pathspec while running in
|
||||
// the package directory; git matched nothing and the guard never fired.
|
||||
func mutationTargetIsDirty(wd, name string) (bool, error) {
|
||||
cmd := exec.Command("git", "diff", "--name-only", "--", "./"+name)
|
||||
cmd.Dir = wd
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(string(out)) != "", nil
|
||||
}
|
||||
|
||||
func countTests(out string) int {
|
||||
n := 0
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
// Subtests are indented; only count top-level ones so a single test
|
||||
// with a table of cases cannot inflate the count.
|
||||
if strings.HasPrefix(line, "=== RUN Test") && !strings.Contains(line, "/") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// behaviourOnly strips the parts of `go test -v` output that vary between runs
|
||||
// (timings, goroutine ids, temp paths) so two runs can be compared for equality.
|
||||
// If a mutant produces output identical to the baseline under this projection,
|
||||
// the mutation did not change observable behaviour and is therefore invalid.
|
||||
func behaviourOnly(out string) string {
|
||||
var kept []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
// Keep only the outcome lines. Test NAMES are deliberately dropped:
|
||||
// two runs of the same suite can legitimately order or cache tests
|
||||
// differently, and keeping the names made every mutant look like it had
|
||||
// "changed behaviour" — which defeats the no-op detector entirely.
|
||||
case strings.HasPrefix(trimmed, "--- PASS"), strings.HasPrefix(trimmed, "--- FAIL"),
|
||||
strings.HasPrefix(trimmed, "--- SKIP"),
|
||||
strings.HasPrefix(trimmed, "ok "), trimmed == "FAIL",
|
||||
strings.Contains(trimmed, ".go:"):
|
||||
kept = append(kept, trimmed)
|
||||
}
|
||||
}
|
||||
// Counting outcomes is stronger than comparing the sequence: a mutation
|
||||
// that swaps which test fails still changes the multiset, and one that
|
||||
// changes nothing cannot.
|
||||
sort.Strings(kept)
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
|
||||
// firstLines trims output for an error message; a failing nested run can be
|
||||
// thousands of lines and the cause is always near the top.
|
||||
func firstLines(s string, n int) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[:n]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// TestMutationFilterCoversEveryGuard keeps the filter honest. Dropping a group
|
||||
// from it silently disarms that area's mutants.
|
||||
func TestMutationFilterCoversEveryGuard(t *testing.T) {
|
||||
for _, group := range []string{
|
||||
"ParseModelCatalog", // nested metadata + non-catalog rejection
|
||||
"DiscoveredModelKind", // kind follows upstream metadata
|
||||
"Discover", // URL fallback, auth classification
|
||||
"ComposeEndpoint", // version segment exactly once (chat)
|
||||
"ComposeImageEndpoint",
|
||||
"ComposeEndpoints", // the composed URLs, both base shapes
|
||||
"IsVersionSegment", // "v1beta" vs "vision"
|
||||
"DiscoveryResult", // nil slices are arrays
|
||||
} {
|
||||
if !strings.Contains(mutationFilter, group) {
|
||||
t.Errorf("mutation filter omits %s; mutants there survive silently", group)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,171 +0,0 @@
|
||||
package gateway
|
||||
|
||||
// Mutation harness for the discovery code. Kept in the tree (not in a shell
|
||||
// script) so the -run filter is a Go string literal rather than something the
|
||||
// shell can mangle into "[no tests to run]" — which is exactly how an earlier
|
||||
// version of this harness reported four surviving mutants that were simply
|
||||
// never executed.
|
||||
//
|
||||
// Each mutant flips one behaviour; TestMutationHarness_Fails proves the
|
||||
// harness itself is armed.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// discoveryTestFilter must cover every assertion that guards the behaviour
|
||||
// below. Narrowing it is how mutants get to "survive" for free.
|
||||
const discoveryTestFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates)"
|
||||
|
||||
func TestDiscoveryFilterRunsRealTests(t *testing.T) {
|
||||
if discoveryTestFilter == "" {
|
||||
t.Fatal("filter is empty; every mutant would survive trivially")
|
||||
}
|
||||
// The whole point: the catalog parser test must be inside the filter, or a
|
||||
// mutant that stops reading nested metadata survives with a green run.
|
||||
for _, group := range []string{
|
||||
"ParseModelCatalog", // nested metadata + non-catalog rejection
|
||||
"DiscoveredModelKind", // kind must follow upstream metadata
|
||||
"Discover", // URL fallback + endpoint composition
|
||||
"ComposeEndpoint", // version segment exactly once
|
||||
"IsVersionSegment", // "v1beta" vs "vision"
|
||||
} {
|
||||
if !strings.Contains(discoveryTestFilter, group) {
|
||||
t.Errorf("filter %q skips %s tests; mutants in that area survive", discoveryTestFilter, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMutantsAreDetectable is the harness's own regression guard: it runs the
|
||||
// filter's worth of assertions against deliberately broken inputs and requires
|
||||
// each to be rejected. If a future edit makes one of these pass, the
|
||||
// corresponding protection is gone.
|
||||
func TestMutantsAreDetectable(t *testing.T) {
|
||||
t.Run("image classification follows upstream metadata, not the model name", func(t *testing.T) {
|
||||
// The mutant: drop the output-modality check, so every model is chat.
|
||||
m := upstreamModel{ID: "gpt-image-2"}
|
||||
m.Architecture.OutputModalities = []string{"image"}
|
||||
if got := discoveredModelKind(m); got != "image" {
|
||||
t.Errorf("mutant survived: got %q, want \"image\"", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested metadata is read", func(t *testing.T) {
|
||||
var ms []upstreamModel
|
||||
if err := json.Unmarshal([]byte(realCatalogBody), &ms); err == nil {
|
||||
t.Fatal("fixture must be a wrapper object, not a bare array")
|
||||
}
|
||||
var wrapper struct {
|
||||
Data []upstreamModel `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(realCatalogBody), &wrapper); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(wrapper.Data) == 0 {
|
||||
t.Fatal("fixture parsed to zero models")
|
||||
}
|
||||
// The mutant: skip copyFields(m.Metadata) entirely.
|
||||
meta := modelMeta(wrapper.Data[0])
|
||||
if meta == nil {
|
||||
t.Fatal("mutant survived: metadata is nil, so cost/context_limit are lost")
|
||||
}
|
||||
for _, k := range []string{"context_limit", "cost", "context_budget"} {
|
||||
if _, ok := meta[k]; !ok {
|
||||
t.Errorf("mutant survived: %s dropped from nested metadata", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-catalog responses are rejected", func(t *testing.T) {
|
||||
for _, body := range []string{`<html>login</html>`, `null`, `{"error":{"message":"x"}}`, `{}`} {
|
||||
if _, err := parseModelCatalog([]byte(body)); err == nil {
|
||||
t.Errorf("mutant survived: %q accepted as a catalog", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version segments are distinguished from real path segments", func(t *testing.T) {
|
||||
if !isVersionSegment("v1beta") {
|
||||
t.Error("mutant survived: v1beta not treated as a version")
|
||||
}
|
||||
for _, seg := range []string{"vision", "vertex", "v1x"} {
|
||||
if isVersionSegment(seg) {
|
||||
t.Errorf("mutant survived: %q wrongly treated as a version", seg)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("endpoint composition never duplicates the version segment", func(t *testing.T) {
|
||||
// The mutant: never move the segment onto the endpoint.
|
||||
if got := composeEndpoint("/v1/models", "https://h", "https://h/v1"); got != "/v1/chat/completions" {
|
||||
t.Errorf("mutant survived: endpoint = %q", got)
|
||||
}
|
||||
// The other mutant: always move it, even when the base keeps it.
|
||||
if got := composeEndpoint("/models", "https://h/v1", "https://h/v1"); got != "" {
|
||||
t.Errorf("mutant survived: endpoint = %q would double /v1", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("image_models is never nil", func(t *testing.T) {
|
||||
res := &discoveryResult{Models: []string{"a"}, ImageModels: nil}
|
||||
b, _ := json.Marshal(res)
|
||||
if strings.Contains(string(b), `"image_models":null`) {
|
||||
t.Errorf("mutant survived: %s", b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a rejected key is reported as unauthorized, not as an outage", func(t *testing.T) {
|
||||
// The mutant: collapse every failure into one generic error, which is
|
||||
// what made a typo'd key look like the vendor being down.
|
||||
if classifyAuthStatus(http.StatusUnauthorized, []byte(`{"error":"invalid api key"}`)) == "" {
|
||||
t.Error("mutant survived: 401 is not classified")
|
||||
}
|
||||
if classifyAuthStatus(http.StatusNotFound, []byte("nope")) != "" {
|
||||
t.Error("a 404 must not be reported as an auth problem")
|
||||
}
|
||||
if classifyAuthStatus(http.StatusOK, nil) != "" {
|
||||
t.Error("a 200 must not be reported as an auth problem")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate candidate URLs are collapsed", func(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, c := range discoveryCandidates("https://h/v1") {
|
||||
u := c.baseURL + c.modelsPath
|
||||
if seen[u] {
|
||||
t.Errorf("mutant survived: %s requested twice", u)
|
||||
}
|
||||
seen[u] = true
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("discovery falls back across URL forms", func(t *testing.T) {
|
||||
// The coverage requirement is that BOTH catalog paths are reachable for
|
||||
// a typed base, and both for a bare host. Asserting an exact count kept
|
||||
// drifting as the candidate list was fixed, and each time it measured
|
||||
// the current list rather than the property that matters.
|
||||
for _, base := range []string{"https://h/v1", "https://h"} {
|
||||
distinct := map[string]bool{}
|
||||
for _, c := range discoveryCandidates(base) {
|
||||
distinct[c.baseURL+c.modelsPath] = true
|
||||
}
|
||||
for _, want := range []string{base + "/v1/models", base + "/models"} {
|
||||
if !distinct[want] {
|
||||
t.Errorf("mutant survived: %s never requests %s", base, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only the catalog is fetched, never each model", func(t *testing.T) {
|
||||
// Guarding a NON-mutation: the design decision that discovery must not
|
||||
// spend money. If a future change adds per-model probes, the counting
|
||||
// test below catches it; this assertion documents the intent.
|
||||
if discoveryModelsPath != "/v1/models" {
|
||||
t.Errorf("catalog path changed to %q", discoveryModelsPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,645 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realCatalogBody is the actual /models response captured from the amr-link
|
||||
// endpoint, not a hand-written fixture. Two properties of it are the reason
|
||||
// this test exists at all:
|
||||
//
|
||||
// - it nests modalities under "architecture", which the naive parser misses;
|
||||
// - it lists five image-generation ids among 34 chat ids, so a parser that
|
||||
// returns everything as "chat" silently poisons the AUTO chain.
|
||||
const realCatalogBody = `{"data":[{"id":"claude-fable-5","object":"model","created":1781081344,"owned_by":"vela","architecture":{"input_modalities":["text","image","file"],"output_modalities":["text"]},"metadata":{"cost":"very_high","capability":"best_quality","context_limit":1000000,"context_budget":232000},"default":false,"enabled":true},{"id":"gpt-image-2","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true},{"id":"deepseek-v4-flash","object":"model","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"metadata":{"cost":"medium","capability":"standard","context_limit":1048000},"enabled":true},{"id":"seedream-5.0-pro","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true}]}`
|
||||
|
||||
func TestParseModelCatalog_RealUpstreamShape(t *testing.T) {
|
||||
models, err := parseModelCatalog([]byte(realCatalogBody))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(models) != 4 {
|
||||
t.Fatalf("want 4 models, got %d", len(models))
|
||||
}
|
||||
|
||||
kind := map[string]string{}
|
||||
for _, m := range models {
|
||||
kind[m.ID] = m.Kind
|
||||
}
|
||||
// The two image generators must be classified by the upstream's declared
|
||||
// output modality, not by their names.
|
||||
for _, id := range []string{"gpt-image-2", "seedream-5.0-pro"} {
|
||||
if kind[id] != "image" {
|
||||
t.Errorf("%s: got kind %q, want \"image\"", id, kind[id])
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"claude-fable-5", "deepseek-v4-flash"} {
|
||||
if kind[id] != "chat" {
|
||||
t.Errorf("%s: got kind %q, want \"chat\"", id, kind[id])
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata the upstream sent must survive; keys it did not send must not
|
||||
// appear as zero values.
|
||||
if got := models[0].Meta["context_limit"]; got == nil {
|
||||
t.Error("claude-fable-5: context_limit dropped (it is nested under metadata in the real response)")
|
||||
} else if f, ok := got.(float64); !ok || f != 1000000 {
|
||||
t.Errorf("context_limit = %#v, want numeric 1000000 so the UI can sort on it", got)
|
||||
}
|
||||
if _, present := models[0].Meta["cost"]; !present {
|
||||
t.Error("claude-fable-5: cost dropped")
|
||||
}
|
||||
// context_budget is present on this entry and must not be dropped either.
|
||||
if _, present := models[0].Meta["context_budget"]; !present {
|
||||
t.Error("claude-fable-5: context_budget dropped")
|
||||
}
|
||||
// deepseek-v4-flash DOES send metadata; gpt-image-2 sends only capability.
|
||||
// Neither may lose it.
|
||||
if models[2].Meta["context_limit"] == nil {
|
||||
t.Error("deepseek-v4-flash: context_limit dropped")
|
||||
}
|
||||
if models[2].Meta["cost"] == nil {
|
||||
t.Error("deepseek-v4-flash: cost dropped")
|
||||
}
|
||||
if _, bogus := models[0].Meta["made_up_field"]; bogus {
|
||||
t.Error("invented a metadata key that the upstream never sent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata is the regression guard
|
||||
// for the failure mode that motivated keeping kind="chat" as the default: a
|
||||
// name-based heuristic marks "ideogram-v2" or "gpt-image-1" as an image model,
|
||||
// but a catalog that also serves chat for that id would then have its chat
|
||||
// entry removed from the AUTO chain. Only the upstream's declared OUTPUT
|
||||
// modality may classify a model as image.
|
||||
func TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in upstreamModel
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// A catalog that DECLARES output modality image is authoritative.
|
||||
// The "kind" string saying chat must not override it.
|
||||
name: "declared output modality image wins over a chat kind string",
|
||||
in: func() upstreamModel {
|
||||
var m upstreamModel
|
||||
m.Kind = "chat"
|
||||
m.Architecture.OutputModalities = []string{"image"}
|
||||
return m
|
||||
}(),
|
||||
want: "image",
|
||||
},
|
||||
{
|
||||
name: "bare array modality string",
|
||||
in: upstreamModel{ID: "x", Modality: "text-to-image"},
|
||||
want: "image",
|
||||
},
|
||||
{
|
||||
name: "image INPUT is still a chat model",
|
||||
in: func() upstreamModel {
|
||||
var m upstreamModel
|
||||
m.Architecture.InputModalities = []string{"text", "image"}
|
||||
return m
|
||||
}(),
|
||||
want: "chat",
|
||||
},
|
||||
{
|
||||
name: "file input is still a chat model",
|
||||
in: func() upstreamModel {
|
||||
var m upstreamModel
|
||||
m.Architecture.InputModalities = []string{"text", "file"}
|
||||
return m
|
||||
}(),
|
||||
want: "chat",
|
||||
},
|
||||
{
|
||||
name: "vision capability flag is chat",
|
||||
in: upstreamModel{ID: "y", Capabilities: modelCaps{Vision: true}},
|
||||
want: "chat",
|
||||
},
|
||||
{
|
||||
name: "capabilities.image_generation is image",
|
||||
in: upstreamModel{ID: "z", Capabilities: modelCaps{ImageGeneration: true}},
|
||||
want: "image",
|
||||
},
|
||||
{
|
||||
name: "no modality info at all defaults to chat",
|
||||
in: upstreamModel{ID: "plain"},
|
||||
want: "chat",
|
||||
},
|
||||
{
|
||||
// The default must be chat even when the name LOOKS like an image
|
||||
// model. Guessing from the name is what puts gpt-image-2 into the
|
||||
// chat AUTO chain, where every request to it fails.
|
||||
name: "image-looking name without upstream metadata stays chat",
|
||||
in: upstreamModel{ID: "gpt-image-2"},
|
||||
want: "chat",
|
||||
},
|
||||
{
|
||||
name: "vision-looking name without upstream metadata stays chat",
|
||||
in: upstreamModel{ID: "ideogram-v2"},
|
||||
want: "chat",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := discoveredModelKind(tc.in); got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelCatalog_RejectsNonCatalog(t *testing.T) {
|
||||
// A 200 with valid JSON that is not a catalog must NOT be reported as an
|
||||
// empty result. Reporting zero models would let a login page or a
|
||||
// rate-limit body create a source with no models in it.
|
||||
for name, body := range map[string]string{
|
||||
"login page html": `<html><body>Sign in</body></html>`,
|
||||
"rate limit json": `{"error":{"message":"slow down","type":"rate_limit"}}`,
|
||||
"empty object": `{}`,
|
||||
"null": `null`,
|
||||
"wrong shape": `{"objects":[{"id":"m"}]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := parseModelCatalog([]byte(body)); err == nil {
|
||||
t.Errorf("expected an error for %q, got nil (would create an empty source)", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelCatalog_AcceptsBareArray(t *testing.T) {
|
||||
models, err := parseModelCatalog([]byte(`[{"id":"a"},{"id":"b"}]`))
|
||||
if err != nil {
|
||||
t.Fatalf("bare array should parse: %v", err)
|
||||
}
|
||||
if len(models) != 2 {
|
||||
t.Fatalf("want 2, got %d", len(models))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers: operator pastes
|
||||
// "https://host/v1" (the form every SDK example shows) but the vendor serves
|
||||
// the catalog at "https://host/models" — the version segment on the path is
|
||||
// implied, not doubled. Discovery must strip the segment off the base and
|
||||
// report it on the endpoint, because openai's adapter endpoint is
|
||||
// "/chat/completions" and the two are concatenated.
|
||||
func TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers(t *testing.T) {
|
||||
var paths []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
if r.URL.Path != "/models" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(realCatalogBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-test")
|
||||
if err != nil {
|
||||
t.Fatalf("discovery failed: %v", err)
|
||||
}
|
||||
if res.BaseURL != srv.URL {
|
||||
t.Errorf("base_url = %q, want %q (version segment moved off the base)", res.BaseURL, srv.URL)
|
||||
}
|
||||
// "/models" carries no version segment, but the operator typed one — the
|
||||
// endpoint has to restore it or the chat call would miss /v1.
|
||||
if res.Endpoint != "/v1/chat/completions" {
|
||||
t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions")
|
||||
}
|
||||
if res.Note == "" {
|
||||
t.Error("silently rewriting the operator's base_url is worse than saying so")
|
||||
}
|
||||
if len(res.Models) != 2 || len(res.ImageModels) != 2 {
|
||||
t.Errorf("got %d chat / %d image, want 2 / 2", len(res.Models), len(res.ImageModels))
|
||||
}
|
||||
if len(paths) < 2 {
|
||||
t.Errorf("tried only %v; expected a fallback search", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_BareHostWithVersionedModelsPath: operator pastes the host
|
||||
// root, vendor serves /v1/models. The base needs no rewriting; the endpoint
|
||||
// does.
|
||||
func TestDiscoverModels_BareHostWithVersionedModelsPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"m1"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := DiscoverModels(context.Background(), srv.URL, "")
|
||||
if err != nil {
|
||||
t.Fatalf("discovery failed: %v", err)
|
||||
}
|
||||
if res.BaseURL != srv.URL {
|
||||
t.Errorf("base_url = %q, want %q", res.BaseURL, srv.URL)
|
||||
}
|
||||
if res.Endpoint != "/v1/chat/completions" {
|
||||
t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_KeepsWorkingBaseURLUnchanged covers the vendor that really
|
||||
// does serve "/v1/models": the typed URL must survive untouched so no needless
|
||||
// config rewrite is suggested.
|
||||
func TestDiscoverModels_KeepsWorkingBaseURLUnchanged(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Base typed WITH "/v1", catalog served at "…/v1/models". That is exactly
|
||||
// the amr-link shape: first candidate succeeds, nothing is rewritten, and
|
||||
// the adapter's default endpoint already composes.
|
||||
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("discovery failed: %v", err)
|
||||
}
|
||||
if res.BaseURL != srv.URL+"/v1" {
|
||||
t.Errorf("base_url = %q, want it unchanged", res.BaseURL)
|
||||
}
|
||||
if res.Endpoint != "" {
|
||||
t.Errorf("endpoint = %q, want empty (the typed base already carries /v1)", res.Endpoint)
|
||||
}
|
||||
if res.Note != "" {
|
||||
t.Errorf("note = %q, want empty when nothing was rewritten", res.Note)
|
||||
}
|
||||
if len(res.Models) != 2 {
|
||||
t.Errorf("got %d chat models, want 2", len(res.Models))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_ErrorNamesEveryTriedURL: a failed probe is useless if it
|
||||
// only reports the last URL. The operator needs to see the full search.
|
||||
func TestDiscoverModels_ErrorNamesEveryTriedURL(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
|
||||
if err == nil {
|
||||
t.Fatal("expected failure against an all-404 server")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"/models", "/v1/models", "404"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error %q should mention %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryCandidates_DedupesAndPrefersTyped(t *testing.T) {
|
||||
cands := discoveryCandidates("https://h/v1/")
|
||||
if len(cands) == 0 {
|
||||
t.Fatal("no candidates")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
// Dedup is on the requested URL, so that is the key to assert on.
|
||||
k := c.baseURL + c.modelsPath
|
||||
if seen[k] {
|
||||
t.Errorf("duplicate request for %s", k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
// The versioned spelling of the typed URL must be reachable: on the real
|
||||
// amr-link endpoint that is what answers, and it is the preferred candidate.
|
||||
if !seen["https://h/v1/models"] {
|
||||
t.Error("the typed base with a versioned catalog path must be tried; that is what answers on amr-link")
|
||||
}
|
||||
// Both catalog forms must be attempted for a bare host too. Offering only
|
||||
// "/v1/models" there left a vendor serving a bare "/models" looking
|
||||
// unreachable.
|
||||
if !seen["https://h/models"] {
|
||||
t.Error("host root with a bare /models path was never tried")
|
||||
}
|
||||
// The first candidate is preferred, so its URL is what gets reported when
|
||||
// several answer.
|
||||
if cands[0].baseURL+cands[0].modelsPath != "https://h/v1/models" {
|
||||
t.Errorf("first candidate requests %q, want the canonical versioned form",
|
||||
cands[0].baseURL+cands[0].modelsPath)
|
||||
}
|
||||
// The bare "/models" form must exist too — some gateways serve it there.
|
||||
hasBare := false
|
||||
for _, c := range cands {
|
||||
if c.modelsPath == "/models" {
|
||||
hasBare = true
|
||||
}
|
||||
}
|
||||
if !hasBare {
|
||||
t.Error("no /models candidate; vendors serving the catalog at /models would be missed")
|
||||
}
|
||||
// Preference must be monotonically non-decreasing so the sort is stable.
|
||||
for i := 1; i < len(cands); i++ {
|
||||
if cands[i].prefer < cands[i-1].prefer {
|
||||
t.Errorf("candidates out of preference order at %d: %d < %d", i, cands[i].prefer, cands[i-1].prefer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionSegment(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/v1/models": "/v1",
|
||||
"/v1beta/models": "/v1beta",
|
||||
"/models": "",
|
||||
"/api/models": "",
|
||||
"/vision/models": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := versionSegment(in); got != want {
|
||||
t.Errorf("versionSegment(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimVersionSuffix(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://h/v1": "https://h",
|
||||
"https://h/v2": "https://h",
|
||||
"https://h/v1beta": "https://h",
|
||||
"https://h/api/v1": "https://h/api",
|
||||
"https://h/vision": "https://h/vision", // not numeric — a real path segment
|
||||
"https://h": "https://h",
|
||||
"https://h/openai": "https://h/openai",
|
||||
"https://h/v1/extra": "https://h/v1/extra",
|
||||
// A segment that merely STARTS with v must not be stripped: stripping
|
||||
// it would rewrite base "https://h/vision" to "https://h" and, worse,
|
||||
// would treat a real vendor path as a version.
|
||||
"https://h/vertex": "https://h/vertex",
|
||||
"https://h/volcengine": "https://h/volcengine",
|
||||
"https://h/vertex-ai/v1": "https://h/vertex-ai",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := trimVersionSuffix(in); got != want {
|
||||
t.Errorf("trimVersionSuffix(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsVersionSegment_RequiresDigits pins the distinction the whole endpoint
|
||||
// derivation rests on: "v1"/"v1beta" are versions, "vision"/"vertex" are path
|
||||
// segments. Without the digit requirement, stripping turns a vendor's real
|
||||
// base URL into a different one.
|
||||
func TestIsVersionSegment_RequiresDigits(t *testing.T) {
|
||||
yes := []string{"v1", "v2", "v10", "v1beta", "v1alpha", "v1preview"}
|
||||
no := []string{"vision", "vertex", "volcengine", "openai", "v", "vv", "api", "beta", "v1x"}
|
||||
for _, s := range yes {
|
||||
if !isVersionSegment(s) {
|
||||
t.Errorf("isVersionSegment(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range no {
|
||||
if isVersionSegment(s) {
|
||||
t.Errorf("isVersionSegment(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposeEndpoint_ExactlyOneVersionSegment is the invariant test. Every row
|
||||
// below is a URL form that was observed to 404 or to route wrongly when the
|
||||
// version segment ended up in the wrong place.
|
||||
func TestComposeEndpoint_ExactlyOneVersionSegment(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
modelsPath string
|
||||
persistedBase string
|
||||
typedBase string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "typed base has /v1 and catalog served there: base keeps it, endpoint empty",
|
||||
modelsPath: "/models", persistedBase: "https://h/v1", typedBase: "https://h/v1",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "catalog served at versioned path, base stripped: endpoint carries it",
|
||||
modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h/v1",
|
||||
want: "/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
name: "bare host, bare catalog, nothing typed: adapter default composes",
|
||||
modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "bare host, versioned catalog: endpoint owns the version",
|
||||
modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h",
|
||||
want: "/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
name: "typed /v1 but catalog only at bare /models: strip base, restore on endpoint",
|
||||
modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h/v1",
|
||||
want: "/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
name: "non-version segment must never be promoted onto the endpoint",
|
||||
modelsPath: "/models", persistedBase: "https://h/vision", typedBase: "https://h/vision",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := composeEndpoint(tc.modelsPath, tc.persistedBase, tc.typedBase)
|
||||
if got != tc.want {
|
||||
t.Errorf("composeEndpoint = %q, want %q", got, tc.want)
|
||||
}
|
||||
// Whichever way it resolved, base + endpoint must never carry the
|
||||
// same version segment twice.
|
||||
onBase := trimVersionSuffix(tc.persistedBase) != tc.persistedBase
|
||||
onEndpoint := versionSegment(strings.TrimSuffix(got, "/chat/completions")+"/models") != ""
|
||||
if onBase && onEndpoint {
|
||||
t.Errorf("version segment duplicated: base %q + endpoint %q", tc.persistedBase, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_DoesNotProbeEveryModel guards the decision not to spend
|
||||
// money verifying each catalog entry. Only the catalog request is made.
|
||||
func TestDiscoverModels_DoesNotProbeEveryModel(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"},{"id":"c"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k"); err != nil {
|
||||
t.Fatalf("discovery failed: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("made %d requests, want exactly 1 (the catalog)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsToConfig_PrioritiesAndKinds(t *testing.T) {
|
||||
got := modelsToConfig([]string{"a", "b"}, []string{"img1"})
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 models, got %d", len(got))
|
||||
}
|
||||
if got[2].ID != "img1" || got[2].Kind != "image" {
|
||||
t.Errorf("image model mis-sorted: %+v", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_ImageModelsNeverNull guards the UI contract end to end,
|
||||
// not just on the struct: the WebUI does out.concat(image_models), and a JSON
|
||||
// null there renders "undefined" next to every image model row. This asserts on
|
||||
// the served bytes rather than the Go field, because that is what the bug was.
|
||||
func TestDiscoverModels_ImageModelsNeverNull(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only chat models — the case where the slice is never appended to.
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
|
||||
if err != nil {
|
||||
t.Fatalf("discovery failed: %v", err)
|
||||
}
|
||||
if len(res.ImageModels) != 0 {
|
||||
t.Fatalf("precondition: want zero image models, got %d", len(res.ImageModels))
|
||||
}
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), `"image_models":[]`) {
|
||||
t.Errorf("served %s\nwant \"image_models\":[] — null breaks concat in the UI", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposeImageEndpoint pins the rule that fixed a real 404.
|
||||
//
|
||||
// Provider.ImageURL is `base_url + ImageEndpoint()`, and the built-in default
|
||||
// is the ABSOLUTE "/v1/images/generations" — while chat's default is the
|
||||
// relative "/chat/completions". The two defaults disagree about who owns the
|
||||
// version segment, so a base_url that produces a working chat URL produces
|
||||
// "/v1/v1/images/generations" and a 404.
|
||||
func TestComposeImageEndpoint(t *testing.T) {
|
||||
cases := []struct {
|
||||
base string
|
||||
want string
|
||||
}{
|
||||
// base without a version: the absolute default composes correctly.
|
||||
{"https://h", ""},
|
||||
// base WITH a version: the default would double it, so the
|
||||
// version-relative form is written instead.
|
||||
{"https://h/v1", "/images/generations"},
|
||||
{"https://h/v1beta", "/images/generations"},
|
||||
// A real path segment that merely starts with v is not a version.
|
||||
{"https://h/vision", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := composeImageEndpoint(tc.base)
|
||||
if got != tc.want {
|
||||
t.Errorf("composeImageEndpoint(%q) = %q, want %q", tc.base, got, tc.want)
|
||||
}
|
||||
// Whatever it decided, base + result must not contain a doubled
|
||||
// version segment — that is the bug this function exists for.
|
||||
joined := tc.base + got
|
||||
if strings.Contains(joined, "/v1/v1") {
|
||||
t.Errorf("composed %q contains a doubled version segment", joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion is the end-to-end
|
||||
// version: a pasted base_url with "/v1" must come back with an image endpoint,
|
||||
// or the image models it reported will all 404 on use.
|
||||
func TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(realCatalogBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.ImageModels) == 0 {
|
||||
t.Fatal("fixture must contain image models for this to mean anything")
|
||||
}
|
||||
if res.BaseURL != srv.URL+"/v1" {
|
||||
t.Fatalf("base = %q, want the typed URL with /v1", res.BaseURL)
|
||||
}
|
||||
if res.ImageEndpoint != "/images/generations" {
|
||||
t.Errorf("image_endpoint = %q, want %q (otherwise the reported image models all 404)",
|
||||
res.ImageEndpoint, "/images/generations")
|
||||
}
|
||||
// The chat side stays empty: base already carries /v1 and the adapter's
|
||||
// relative default composes.
|
||||
if res.Endpoint != "" {
|
||||
t.Errorf("endpoint = %q, want empty", res.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryResult_NilSlicesSerializeAsArrays covers the zero value of
|
||||
// discoveryResult, which is what a caller gets if it builds one directly
|
||||
// instead of going through DiscoverModels. DiscoverModels happens to
|
||||
// initialize the slices, so testing only that path would let the MarshalJSON
|
||||
// nil guard look redundant — and removing it would ship a latent UI bug the
|
||||
// first time some other caller builds the struct.
|
||||
func TestDiscoveryResult_NilSlicesSerializeAsArrays(t *testing.T) {
|
||||
b, err := json.Marshal(&discoveryResult{BaseURL: "https://h"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{`"models":[]`, `"image_models":[]`, `"tried":[]`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("zero value serialized %s\nwant %s — a nil slice becomes JSON null and the UI renders \"undefined\"", got, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{`"models":null`, `"image_models":null`, `"tried":null`} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("serialized %s, which the UI cannot concatenate", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryResultJSONShape pins the field names the WebUI will bind to.
|
||||
func TestDiscoveryResultJSONShape(t *testing.T) {
|
||||
b, err := json.Marshal(&discoveryResult{
|
||||
BaseURL: "https://h", Models: []string{"a"}, ImageModels: []string{},
|
||||
ModelsPath: "/models", Adapter: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// image_models must serialize as [] and never null: the UI does
|
||||
// out.concat(image_models) and null would render "undefined".
|
||||
if v, ok := got["image_models"]; !ok || v == nil {
|
||||
t.Errorf("image_models = %v, want an empty array (null breaks concat in the UI)", v)
|
||||
}
|
||||
for _, k := range []string{"base_url", "endpoint", "models", "image_models", "models_path", "tried"} {
|
||||
if _, ok := got[k]; !ok {
|
||||
t.Errorf("missing field %q in discovery response", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,8 +14,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
)
|
||||
|
||||
func mockUpstream() *httptest.Server {
|
||||
@ -53,48 +53,12 @@ func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway {
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 测试默认建一条 AUTO 链(每个源一个 tier,按传入顺序),因为内核
|
||||
// **不再** seedAuto 自动播种规则了(那条废弃逻辑已删除:AUTO 链为空时
|
||||
// 网关应当直接 503,而不是凭空生成一堆用户没配过的档位)。
|
||||
//
|
||||
// 但既有测试大量依赖"AUTO 能跑",它们此前是隐式吃 seedAuto 的。这里显式
|
||||
// 建链,既保持测试原意(验调度而不是验播种),也让"空链=503"成为
|
||||
// 一个**可以单独断言**的行为(见 TestChatAutoEmptyChainIsRejected)。
|
||||
//
|
||||
// 顺序沿用 seedAuto 的老口径:**源按"最后一个最先"排**(tier 1 = 最后
|
||||
// 传入的源)。既有用例靠这个语义挑"high"源(TestChatAUTO 期望 high-m),
|
||||
// 改成正序会让一批用例因为"挑到了另一个源"而红,而那与本次重构无关。
|
||||
//
|
||||
// 所有源放在**同一个 tier**(tier 1),而不是一个源一个 tier:链内同一
|
||||
// tier 的多个 slot 会互相 failover,这正是 TestChatAutoChainTierFailover
|
||||
// 要验的行为("a 挂 500 → 请求由 b 服务")。一源一 tier 会让 a 独占 tier1,
|
||||
// b 在 tier2 永远轮不到,"两个源都被打过"这条断言就必然失败。
|
||||
if len(cfg.Auto) == 0 && len(srcs) > 0 {
|
||||
// 一源一 tier,**倒序**:最后传入的源拿 tier 1(最先尝试)。
|
||||
// TestChatAUTO(low 先传、high 后传,期望 high)靠的就是这个语义。
|
||||
// 同 tier failover 由 TestChatAutoChainTierFailover 自己显式建链覆盖 ——
|
||||
// 两条测试验的是不同机制(tier 优先 vs 同 tier 轮转),不能用同一个
|
||||
// 默认夹具同时满足。
|
||||
tier := 0
|
||||
for i := len(srcs) - 1; i >= 0; i-- {
|
||||
tier++
|
||||
s := srcs[i]
|
||||
for _, m := range s.Models {
|
||||
if m.Kind == "image" {
|
||||
continue // 图片模型不进 chat 链(与 buildAutoChain 同口径)
|
||||
}
|
||||
cfg.Auto = append(cfg.Auto, config.ModelScope{
|
||||
Model: m.ID, Source: s.Name, Tier: tier,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
g, err := New(c)
|
||||
g, err := New(c, []string{"sk-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
@ -143,18 +107,9 @@ func TestChatAutoChainTierFailover(t *testing.T) {
|
||||
defer aUp.Close()
|
||||
defer bUp.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m"}}},
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
|
||||
)
|
||||
// ★ 显式把两个源放进**同一 tier**:本用例验的是 tier 内失败轮转。
|
||||
// 默认夹具是一源一 tier(供 TestChatAUTO 验优先级),两者语义不同,
|
||||
// 必须在这里覆盖,否则 b 在 tier2 永远轮不到。
|
||||
if err := g.core.SaveAutoRules([]config.ModelScope{
|
||||
{Model: "a-m", Source: "a", Tier: 1},
|
||||
{Model: "b-m", Source: "b", Tier: 1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save auto rules: %v", err)
|
||||
}
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != 200 {
|
||||
@ -179,8 +134,8 @@ func TestChatAutoChain503Summary(t *testing.T) {
|
||||
defer aUp.Close()
|
||||
defer bUp.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m"}}},
|
||||
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
@ -201,7 +156,7 @@ func TestChatAutoQuotaSkip(t *testing.T) {
|
||||
up := upstream(t, ctrl)
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
)
|
||||
// one slot with an hourly quota of 1 token
|
||||
rr := doReq(t, g, "PUT", "/api/auto",
|
||||
@ -236,7 +191,7 @@ func TestAutoStatesReportChainHealth(t *testing.T) {
|
||||
up := upstream(t, ctrl)
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
)
|
||||
doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
@ -279,7 +234,7 @@ func TestAutoSaveResetsCooldown(t *testing.T) {
|
||||
up := upstream(t, ctrl)
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
|
||||
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
@ -393,8 +348,8 @@ func TestChatAUTO(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "low", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "low-m"}}},
|
||||
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m"}}},
|
||||
config.Source{Name: "low", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "low-m", Priority: 10}}},
|
||||
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m", Priority: 100}}},
|
||||
)
|
||||
// no model -> AUTO -> picks the highest priority source
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
@ -473,8 +428,8 @@ func TestImageAutoFallsOnlyToImageProviders(t *testing.T) {
|
||||
}))
|
||||
defer chatUp.Close()
|
||||
g := newTestGateway(t,
|
||||
config.Source{Name: "chat", BaseURL: chatUp.URL, Adapter: "openai", Models: []config.Model{{ID: "chat-m"}}},
|
||||
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image"}}},
|
||||
config.Source{Name: "chat", BaseURL: chatUp.URL, Adapter: "openai", Models: []config.Model{{ID: "chat-m", Priority: 100}}},
|
||||
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image", Priority: 1}}},
|
||||
)
|
||||
rr := doReq(t, g, "POST", "/v1/images/generations",
|
||||
`{"model":"AUTO","prompt":"a cat"}`)
|
||||
@ -489,6 +444,38 @@ func TestImageAutoFallsOnlyToImageProviders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimicodeSigning(t *testing.T) {
|
||||
var gotAuth, gotSign string
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.ReadAll(r.Body)
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotSign = r.Header.Get("X-App-Sign")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
g := newTestGateway(t, config.Source{
|
||||
Name: "kimi",
|
||||
BaseURL: up.URL,
|
||||
Adapter: "kimicode",
|
||||
APIKey: "sk-kimi",
|
||||
Models: []config.Model{{ID: "kimi-k2"}},
|
||||
Meta: map[string]interface{}{"app_id": "app-1", "app_secret": "s3cr3t", "app_agent": "code-agent", "api_key": "sk-kimi"},
|
||||
})
|
||||
rr := doReq(t, g, "POST", "/v1/chat/completions",
|
||||
`{"model":"kimi-k2","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if gotAuth == "" || !strings.Contains(gotAuth, "sk-kimi") {
|
||||
t.Fatalf("expected signed auth, got %q", gotAuth)
|
||||
}
|
||||
if gotSign == "" {
|
||||
t.Fatalf("expected app signature header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRoutingPrefix(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
|
||||
@ -1,192 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Electron shell (cmd/gui) had NO tests at all, so the plugin panel went in
|
||||
// with references to CSS classes that do not exist (.tag, .sm) and to helper
|
||||
// functions that were never defined in that document (esc / escAttr). All of
|
||||
// it rendered as unstyled text and would have thrown a ReferenceError at click
|
||||
// time — and none of that is visible without opening the app.
|
||||
//
|
||||
// These tests are deliberately static. They do not launch Electron: what they
|
||||
// guard is the class of mistake that "looks fine until someone themes it",
|
||||
// which is exactly what a missing CSS class or a missing helper is.
|
||||
|
||||
func guiFile(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
// The tests live in internal/gateway, so walk up to the repo root.
|
||||
p := filepath.Join("..", "..", rel)
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Skipf("%s not readable: %v", rel, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// classUseRe finds class="..." occurrences in a document.
|
||||
var classUseRe = regexp.MustCompile(`class="([^"]+)"`)
|
||||
|
||||
// classTokenRe matches ANY ".name" inside the stylesheet. Deliberately loose:
|
||||
// it also matches inside compound selectors (".tb-btn.tb-close:hover" must count
|
||||
// as defining .tb-close, which a "must be at the start of a selector" rule
|
||||
// misses) and inside comments, which only ever makes the check MORE permissive.
|
||||
// A false pass here would be bad, so the strictness lives elsewhere: the
|
||||
// variable below is what actually guards the new code.
|
||||
var classTokenRe = regexp.MustCompile(`\.([A-Za-z_][A-Za-z0-9_-]*)`)
|
||||
|
||||
// guiKnownUnstyled lists classes the shell markup has always used with no
|
||||
// matching rule. They are pre-existing cosmetic gaps, not regressions, and
|
||||
// failing on them would make this test useless as a guard for NEW work.
|
||||
var guiKnownUnstyled = map[string]bool{
|
||||
"blob": true, // decorative blur blobs, styled per-instance via .b1/.b2/.b3
|
||||
"tgl": true, // rail toggle affordance that leaned on .rail-btn
|
||||
"rail": true, // the rail container itself has no rule; .rail-btn children carry the look
|
||||
}
|
||||
|
||||
// TestGUICSSClassesExist is the guard that would have caught .tag and .sm: every
|
||||
// class used in the shell's markup must be defined in its stylesheet.
|
||||
//
|
||||
// The comparison is on the LAST segment of a selector, because the stylesheet
|
||||
// scopes things (`.form .actions .primary`, `#bgfx .b1`): a rule for
|
||||
// `.pl-acts button` defines no class at all, and `.form .row .toggle` defines
|
||||
// `.toggle`. Requiring a top-level class would be too strict; requiring that
|
||||
// some selector's last identifier matches is the right level.
|
||||
func TestGUICSSClassesExist(t *testing.T) {
|
||||
html := guiFile(t, "cmd/gui/renderer/index.html")
|
||||
css := guiFile(t, "cmd/gui/renderer/style.css")
|
||||
|
||||
defined := map[string]bool{}
|
||||
|
||||
// Collect every class token that appears at the START of a selector
|
||||
// position. A full CSS parser is overkill and was the source of two wrong
|
||||
// turns here; what the check needs is simply "does the name .foo appear
|
||||
// anywhere in the stylesheet as a selector component".
|
||||
//
|
||||
// Scoping is respected loosely: `.form .actions .primary` counts as
|
||||
// defining `.primary`, and `.pl-acts button` defines no class — which is
|
||||
// exactly why the plugin panel needed its own rules.
|
||||
for _, m := range classTokenRe.FindAllStringSubmatch(css, -1) {
|
||||
defined[m[1]] = true
|
||||
}
|
||||
if len(defined) == 0 {
|
||||
t.Fatal("no classes parsed from the stylesheet; the check is broken")
|
||||
}
|
||||
|
||||
// Classes the JS builds as strings must exist too.
|
||||
js := guiFile(t, "cmd/gui/renderer/app.js")
|
||||
var missing []string
|
||||
seen := map[string]bool{}
|
||||
note := func(cls, where string) {
|
||||
// A "${...}" token is a template literal being spliced at runtime, not
|
||||
// a class name; the classes it can expand to are checked at their
|
||||
// definition sites instead.
|
||||
if cls == "" || strings.ContainsAny(cls, "${}") || seen[cls] {
|
||||
return
|
||||
}
|
||||
seen[cls] = true
|
||||
if guiKnownUnstyled[cls] {
|
||||
return
|
||||
}
|
||||
if !defined[cls] {
|
||||
missing = append(missing, cls+" ("+where+")")
|
||||
}
|
||||
}
|
||||
for _, m := range classUseRe.FindAllStringSubmatch(html, -1) {
|
||||
for _, c := range strings.Fields(m[1]) {
|
||||
note(c, "index.html")
|
||||
}
|
||||
}
|
||||
for _, m := range classUseRe.FindAllStringSubmatch(js, -1) {
|
||||
for _, c := range strings.Fields(m[1]) {
|
||||
note(c, "app.js")
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("classes used but not defined in style.css (they render unstyled):\n %s",
|
||||
strings.Join(missing, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGUIHelperFunctionsAreDefined catches the other half: renderer/app.js is a
|
||||
// separate document from the WebUI, so it does NOT have the WebUI's esc/escAttr.
|
||||
// Referencing them gives a ReferenceError only when the line runs.
|
||||
func TestGUIHelperFunctionsAreDefined(t *testing.T) {
|
||||
js := guiFile(t, "cmd/gui/renderer/app.js")
|
||||
for _, fn := range []string{"esc", "escAttr", "toast", "loadPlugins", "togglePlugin", "enableAllPlugins"} {
|
||||
defined := regexp.MustCompile(`function ` + fn + `\b`).MatchString(js)
|
||||
called := regexp.MustCompile(`\b` + fn + `\s*\(`).MatchString(js)
|
||||
if called && !defined {
|
||||
t.Errorf("%s() is called but never defined in app.js", fn)
|
||||
}
|
||||
if !called && !defined {
|
||||
// A defined-but-unused helper is dead code, not an error.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGUIPluginPanelIsReachable: the panel must be inside the settings overlay
|
||||
// AND the settings overlay must actually open it. A panel wired to a button
|
||||
// that was never bound is invisible-but-present, which passes a grep review.
|
||||
func TestGUIPluginPanelIsReachable(t *testing.T) {
|
||||
html := guiFile(t, "cmd/gui/renderer/index.html")
|
||||
js := guiFile(t, "cmd/gui/renderer/app.js")
|
||||
if !strings.Contains(html, `id="pl-list"`) {
|
||||
t.Error("no #pl-list in the settings overlay")
|
||||
}
|
||||
if !strings.Contains(html, `id="settings-overlay"`) {
|
||||
t.Fatal("the settings overlay is gone")
|
||||
}
|
||||
// inside the overlay: the element index must come after the overlay's
|
||||
if strings.Index(html, `id="settings-overlay"`) > strings.Index(html, `id="pl-list"`) {
|
||||
t.Error("#pl-list appears before the settings overlay, so it renders outside the panel")
|
||||
}
|
||||
// The buttons must be bound.
|
||||
for _, id := range []string{"pl-reload", "pl-toggle-all"} {
|
||||
if !strings.Contains(html, `id="`+id+`"`) {
|
||||
t.Errorf("#%s is missing from the markup", id)
|
||||
}
|
||||
if !strings.Contains(js, `"`+id+`"`) {
|
||||
t.Errorf("#%s exists but app.js never binds it", id)
|
||||
}
|
||||
}
|
||||
// And openSettings must trigger the load, or the panel shows a stale empty
|
||||
// list on every open.
|
||||
if !strings.Contains(js, "loadPlugins()") {
|
||||
t.Error("app.js never calls loadPlugins()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGUIIPCPathIsConstrained: plugins:proxy is a raw pass-through, which is
|
||||
// convenient but would be a hole if it accepted arbitrary paths. The main
|
||||
// process must reject anything outside /api/plugins and any traversal.
|
||||
func TestGUIIPCPathIsConstrained(t *testing.T) {
|
||||
main := guiFile(t, "cmd/gui/main.js")
|
||||
for _, needle := range []string{
|
||||
`path.startsWith("/api/plugins")`,
|
||||
`path.includes("..")`,
|
||||
"plugins:proxy",
|
||||
"unsealViaCore()",
|
||||
} {
|
||||
if !strings.Contains(main, needle) {
|
||||
t.Errorf("cmd/gui/main.js is missing the guard %q", needle)
|
||||
}
|
||||
}
|
||||
// The plugins channel must be a proxy, not a key passthrough: the renderer
|
||||
// sends (method, path) and the main process attaches the key.
|
||||
//
|
||||
// NOTE: preload does expose a pre-existing `core.key` getter — the shell
|
||||
// needs the admin key to load the embedded WebUI without a login. That is
|
||||
// existing, deliberate design and out of scope here; asserting on "key:" in
|
||||
// preload would flag a pre-existing feature as a new hole.
|
||||
pre := guiFile(t, "cmd/gui/preload.js")
|
||||
if !strings.Contains(pre, "request: (method, path, body)") {
|
||||
t.Error("preload does not expose the plugins request proxy")
|
||||
}
|
||||
}
|
||||
@ -1,257 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// Per-key AUTO chain admin API. The behaviours pinned here are the ones that
|
||||
// are easy to get wrong and invisible when they are:
|
||||
//
|
||||
// - /api/keys/me/auto must be reachable by a NON-admin (it is their own
|
||||
// chain); routing it through the generic {key}/auto handler would 403 it.
|
||||
// - /api/keys/{key}/auto must 403 a non-admin.
|
||||
// - an empty PUT and a DELETE mean the same thing ("inherit global"), so the
|
||||
// WebUI cannot persist a chain that would then 503 with no slots.
|
||||
// - a PUT that resolves to zero slots is reported back, so an admin who
|
||||
// mistyped a model learns it now instead of from the user's next 503.
|
||||
|
||||
// keyAutoGateway builds a gateway with one admin key and one user key.
|
||||
func keyAutoGateway(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
td := t.TempDir()
|
||||
cfgPath := filepath.Join(td, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte("listen: :0"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: cfgPath,
|
||||
AdapterDir: filepath.Join(td, "adapters"),
|
||||
RuntimeFile: filepath.Join(td, "runtime.json"),
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-admin"},
|
||||
Keys: []config.GWKey{
|
||||
{Key: "sk-admin", Role: "admin", Name: "admin"},
|
||||
{Key: "sk-user", Role: "user", Name: "user"},
|
||||
},
|
||||
Sources: []config.Source{
|
||||
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
Models: []config.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}},
|
||||
},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// doReqAs issues an authenticated request as a specific key.
|
||||
func doReqAs(t *testing.T, g *Gateway, key, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestKeyAutoAPIPermissions(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
// A user may read their own chain...
|
||||
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/me/auto", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/keys/me/auto as user = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
// ...but not another key's.
|
||||
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/sk-admin/auto", ""); rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("GET other key's auto as user = %d, want 403", rr.Code)
|
||||
}
|
||||
// ...and not their own via the admin route either.
|
||||
if rr := doReqAs(t, g, "sk-user", "PUT", "/api/keys/sk-user/auto", `{"auto":[{"model":"gpt-4o"}]}`); rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("user PUT own auto = %d, want 403 (only admin configures)", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyAutoAPICrudAndInherit(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
// Initially inherits.
|
||||
var got struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
Auto []config.ModelScope `json:"auto"`
|
||||
}
|
||||
rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Inherits || len(got.Auto) != 0 {
|
||||
t.Fatalf("fresh key must inherit global, got inherits=%v auto=%v", got.Inherits, got.Auto)
|
||||
}
|
||||
|
||||
// Admin sets a chain; the response reports the resolved slot count.
|
||||
rr = doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
|
||||
`{"auto":[{"model":"gpt-4o-mini","source":"s1","tier":1}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var put struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
Slots int `json:"slots"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &put); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if put.Inherits {
|
||||
t.Fatal("after a non-empty PUT the key must no longer inherit")
|
||||
}
|
||||
if put.Slots != 1 {
|
||||
t.Fatalf("resolved slots = %d, want 1", put.Slots)
|
||||
}
|
||||
|
||||
// And it reads back as an own chain.
|
||||
rr = doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Inherits || len(got.Auto) != 1 || got.Auto[0].Model != "gpt-4o-mini" {
|
||||
t.Fatalf("own chain must read back, got inherits=%v auto=%v", got.Inherits, got.Auto)
|
||||
}
|
||||
|
||||
// DELETE restores inheritance.
|
||||
if rr := doReqAs(t, g, "sk-admin", "DELETE", "/api/keys/sk-user/auto", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
rr = doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Inherits {
|
||||
t.Fatal("after DELETE the key must inherit the global chain again")
|
||||
}
|
||||
}
|
||||
|
||||
// An empty PUT must behave like DELETE. Otherwise a WebUI that submits an
|
||||
// empty list (every slot deleted in the editor) would persist an empty chain
|
||||
// and the next AUTO request would fail with no_provider.
|
||||
func TestKeyAutoAPIEmptyPutClearsOverride(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
if rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
|
||||
`{"auto":[{"model":"gpt-4o","source":"s1","tier":1}]}`); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed PUT = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto", `{"auto":[]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("empty PUT = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
}
|
||||
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", ""); rr.Code != http.StatusOK {
|
||||
t.Fatal(rr.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Inherits {
|
||||
t.Fatal("an empty PUT must clear the override, not persist an empty chain")
|
||||
}
|
||||
}
|
||||
|
||||
// A chain naming a model that does not exist compiles to zero slots. The API
|
||||
// must say so at write time instead of letting the user's next request 503.
|
||||
func TestKeyAutoAPIReportsUnresolvableSlots(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
|
||||
`{"auto":[{"model":"does-not-exist","source":"s1","tier":1}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var put struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
Slots int `json:"slots"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &put); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if put.Slots != 0 {
|
||||
t.Fatalf("unknown model must resolve to 0 slots, got %d", put.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyAutoAPIUnknownKey404(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-nope/auto", ""); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET unknown key auto = %d, want 404", rr.Code)
|
||||
}
|
||||
if rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-nope/auto", `{"auto":[{"model":"gpt-4o"}]}`); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("PUT unknown key auto = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A bad quota must be rejected with 400, not persisted and not silently
|
||||
// clamped — same rule as the model scope path.
|
||||
func TestKeyAutoAPIRejectsBadQuota(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
rr := doReqAs(t, g, "sk-admin", "PUT", "/api/keys/sk-user/auto",
|
||||
`{"auto":[{"model":"gpt-4o","source":"s1","tier":1,"token_quota":-1}]}`)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("negative quota PUT = %d, want 400 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
// Nothing may have been persisted.
|
||||
var got struct {
|
||||
Inherits bool `json:"inherits"`
|
||||
}
|
||||
get := doReqAs(t, g, "sk-admin", "GET", "/api/keys/sk-user/auto", "")
|
||||
if get.Code != http.StatusOK {
|
||||
t.Fatal(get.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(get.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Inherits {
|
||||
t.Fatal("a rejected PUT must not persist a chain")
|
||||
}
|
||||
}
|
||||
|
||||
// The other key endpoints must keep working: the {key}/auto route must not
|
||||
// shadow /api/keys/{key} itself.
|
||||
func TestKeyAutoAPIDoesNotShadowKeyRoutes(t *testing.T) {
|
||||
g := keyAutoGateway(t)
|
||||
|
||||
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/keys = %d, want 200", rr.Code)
|
||||
}
|
||||
if rr := doReqAs(t, g, "sk-admin", "GET", "/api/keys/me", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/keys/me = %d, want 200", rr.Code)
|
||||
}
|
||||
if rr := doReqAs(t, g, "sk-user", "GET", "/api/keys/me", ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/keys/me as user = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// adminGateway builds a gateway whose admin key can call /api/keys.
|
||||
func adminGateway(t *testing.T, keys ...config.GWKey) *Gateway {
|
||||
t.Helper()
|
||||
td := t.TempDir()
|
||||
cfgPath := td + "/config.yaml"
|
||||
if err := os.WriteFile(cfgPath, []byte("listen: :0"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: cfgPath,
|
||||
AdapterDir: td + "/adapters",
|
||||
RuntimeFile: td + "/runtime.json",
|
||||
Keys: keys,
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func adminReq(t *testing.T, g *Gateway, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer sk-admin")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// keyRecord pulls one key's stored record out of the admin list.
|
||||
func keyRecord(t *testing.T, g *Gateway, secret string) config.GWKey {
|
||||
t.Helper()
|
||||
rr := adminReq(t, g, "GET", "/api/keys", "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("GET /api/keys: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Keys []config.GWKey `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, rr.Body.String())
|
||||
}
|
||||
for _, k := range out.Keys {
|
||||
if k.Key == secret {
|
||||
return k
|
||||
}
|
||||
}
|
||||
t.Fatalf("key %q not found in %s", secret, rr.Body.String())
|
||||
return config.GWKey{}
|
||||
}
|
||||
|
||||
func scopeOf(t *testing.T, k config.GWKey, model string) config.ModelScope {
|
||||
t.Helper()
|
||||
for _, m := range k.Models {
|
||||
if m.Model == model {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("scope %q not found in %+v", model, k.Models)
|
||||
return config.ModelScope{}
|
||||
}
|
||||
|
||||
// Quotas live on the scope entries, not on the key: creating a key with a
|
||||
// budget means creating scopes that carry it, and they must survive a
|
||||
// read-back (persisted, not just echoed).
|
||||
func TestKeyAPICreatesPerModelQuota(t *testing.T) {
|
||||
g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"})
|
||||
rr := adminReq(t, g, "POST", "/api/keys",
|
||||
`{"name":"agent-x","role":"user","models":[{"model":"m1","token_quota":50000,"req_quota":200,"period":"nhour","hours":6}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
Key config.GWKey `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
sc := scopeOf(t, created.Key, "m1")
|
||||
if sc.TokenQuota != 50000 || sc.ReqQuota != 200 || sc.Period != "nhour" || sc.Hours != 6 {
|
||||
t.Fatalf("created scope did not carry the caps: %+v", sc)
|
||||
}
|
||||
back := scopeOf(t, keyRecord(t, g, created.Key.Key), "m1")
|
||||
if back.TokenQuota != 50000 || back.ReqQuota != 200 || back.Period != "nhour" || back.Hours != 6 {
|
||||
t.Errorf("read-back lost the caps: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// Two models on one key carry independent budgets.
|
||||
func TestKeyAPIKeepsPerModelQuotaIndependent(t *testing.T) {
|
||||
g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"})
|
||||
rr := adminReq(t, g, "POST", "/api/keys",
|
||||
`{"name":"agent-y","role":"user","models":[{"model":"m1","token_quota":1000,"period":"hour"},{"model":"m2","token_quota":9999,"req_quota":7,"period":"week"}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
Key config.GWKey `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &created)
|
||||
back := keyRecord(t, g, created.Key.Key)
|
||||
if a := scopeOf(t, back, "m1"); a.TokenQuota != 1000 || a.ReqQuota != 0 || a.Period != "hour" {
|
||||
t.Errorf("m1 caps wrong: %+v", a)
|
||||
}
|
||||
if b := scopeOf(t, back, "m2"); b.TokenQuota != 9999 || b.ReqQuota != 7 || b.Period != "week" {
|
||||
t.Errorf("m2 caps wrong: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// Sending 0 explicitly lifts that model's cap.
|
||||
func TestKeyAPIUpdateZeroLiftsCap(t *testing.T) {
|
||||
g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"})
|
||||
rr := adminReq(t, g, "POST", "/api/keys",
|
||||
`{"name":"z","role":"user","models":[{"model":"m1","token_quota":1000,"period":"hour"}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
Key config.GWKey `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &created)
|
||||
secret := created.Key.Key
|
||||
|
||||
rr = adminReq(t, g, "PUT", "/api/keys/"+secret,
|
||||
`{"models":[{"model":"m1","token_quota":0,"req_quota":0,"period":""}]}`)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("lift: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if back := scopeOf(t, keyRecord(t, g, secret), "m1"); back.TokenQuota != 0 || back.ReqQuota != 0 || back.Period != "" {
|
||||
t.Errorf("caps not lifted: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// A misspelled period must be refused, not quietly turned into an all-time
|
||||
// quota — which is the exact opposite of what the operator typed.
|
||||
func TestKeyAPIRejectsBadPeriod(t *testing.T) {
|
||||
g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"})
|
||||
rr := adminReq(t, g, "POST", "/api/keys",
|
||||
`{"name":"bad","role":"user","models":[{"model":"m1","token_quota":1000,"period":"houre"}]}`)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for a bad period, got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "period") {
|
||||
t.Errorf("error should name the period field: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyAPIRejectsNegativeQuota(t *testing.T) {
|
||||
g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"})
|
||||
rr := adminReq(t, g, "POST", "/api/keys",
|
||||
`{"name":"bad","role":"user","models":[{"model":"m1","token_quota":-5}]}`)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for a negative quota, got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The rejection must say WHICH model is over budget, so an operator looking at
|
||||
// a key with a dozen scopes can tell which one to raise.
|
||||
func TestKeyAPIRejectionNamesTheModel(t *testing.T) {
|
||||
g := adminGateway(t, config.GWKey{Key: "sk-admin", Role: "admin"})
|
||||
rr := adminReq(t, g, "POST", "/api/keys",
|
||||
`{"name":"bad","role":"user","models":[{"model":"m1","req_quota":-1}]}`)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "m1") {
|
||||
t.Errorf("error should name the offending model: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A non-admin key must not be able to mint keys.
|
||||
func TestKeyAPIQuotaIsAdminOnly(t *testing.T) {
|
||||
g := adminGateway(t,
|
||||
config.GWKey{Key: "sk-admin", Role: "admin"},
|
||||
config.GWKey{Key: "sk-u", Role: "user", Models: []config.ModelScope{{Model: "m1", TokenQuota: 10, Period: "hour"}}},
|
||||
)
|
||||
req, _ := http.NewRequest("POST", "/api/keys",
|
||||
strings.NewReader(`{"name":"x","role":"admin","models":[{"model":"m1"}]}`))
|
||||
req.Header.Set("Authorization", "Bearer sk-u")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin create: want 403, got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// /api/v1/keys exposes the per-model caps but never a secret
|
||||
rr = adminReq(t, g, "GET", "/api/v1/keys", "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("GET /api/v1/keys: %d", rr.Code)
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), "sk-u") {
|
||||
t.Error("/api/v1/keys leaked a key secret")
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"token_quota":10`) {
|
||||
t.Errorf("/api/v1/keys should expose the per-model caps: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A user must be able to see their own budget: /api/keys/me is the only key
|
||||
// view a non-admin gets, so a cap missing from it is invisible to the very
|
||||
// client it constrains.
|
||||
func TestKeyMeExposesOwnQuota(t *testing.T) {
|
||||
g := adminGateway(t,
|
||||
config.GWKey{Key: "sk-admin", Role: "admin"},
|
||||
config.GWKey{Key: "sk-u", Role: "user", Name: "agent",
|
||||
Models: []config.ModelScope{{Model: "m1", TokenQuota: 123456, ReqQuota: 42, Period: "week"}}},
|
||||
)
|
||||
req, _ := http.NewRequest("GET", "/api/keys/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer sk-u")
|
||||
rr := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("GET /api/keys/me: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// the endpoint wraps the record: {"key": {...}}
|
||||
var wrap struct {
|
||||
Key config.GWKey `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &wrap); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, rr.Body.String())
|
||||
}
|
||||
sc := scopeOf(t, wrap.Key, "m1")
|
||||
if sc.TokenQuota != 123456 || sc.ReqQuota != 42 || sc.Period != "week" {
|
||||
t.Errorf("own quota not visible to the key's owner: %+v", sc)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = fmt.Sprintf
|
||||
@ -1,60 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The per-key quota buckets are shared maps mutated on every recorded request
|
||||
// and read on every quota check. Single-threaded unit tests cannot see a race
|
||||
// there at all, so this drives 64 goroutines through record + read together
|
||||
// under `go test -race`. It also opts one model into the lazily-created pinned
|
||||
// bucket mid-flight, which is the path that mutates two bucket maps at once.
|
||||
func TestConcurrentQuotaAccounting(t *testing.T) {
|
||||
s := NewStats(0)
|
||||
keys := []string{"k0", "k1", "k2", "k3", "k4", "k5", "k6"}
|
||||
models := []string{"m0", "m1", "m2", "AUTO"}
|
||||
var wg sync.WaitGroup
|
||||
stop := time.Now().Add(2 * time.Second)
|
||||
|
||||
for w := 0; w < 64; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
k := keys[w%len(keys)]
|
||||
m := models[w%len(models)]
|
||||
now := time.Now()
|
||||
for time.Now().Before(stop) {
|
||||
s.Record(Req{Time: now.UnixMilli(), Key: k, Model: m, Source: "src",
|
||||
Prompt: 100, Compl: 50, OK: true, Status: 200, Type: "chat"})
|
||||
_ = s.KeyWindowTokens(k, 3600)
|
||||
_ = s.KeyWindowReqs(k, 3600)
|
||||
_ = s.KeyWindowModelTokens(k, m, "", 3600)
|
||||
_ = s.KeyWindowModelTokens(k, m, "src", 3600) // opts into the pinned bucket
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Every recorded request must be accounted for exactly once.
|
||||
s.mu.Lock()
|
||||
var total int64
|
||||
for _, hm := range s.keyHour {
|
||||
for _, v := range hm {
|
||||
total += v
|
||||
}
|
||||
}
|
||||
recs := int64(0)
|
||||
for _, r := range s.recs {
|
||||
recs += r.Prompt + r.Compl
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if total == 0 {
|
||||
t.Fatal("no tokens accounted")
|
||||
}
|
||||
t.Logf("accounted %d tokens across %d keys", total, len(keys))
|
||||
if recs == 0 {
|
||||
t.Error("no records retained; the concurrent writers lost work")
|
||||
}
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Perf: the per-key quota bookkeeping runs on every recorded request and on
|
||||
// every quota check, so its cost lands directly on the request path. These
|
||||
// benchmarks exist to catch a regression that would make the feature
|
||||
// expensive; the numbers that matter are relative to each other and to the
|
||||
// pre-change baseline (Record was 275 ns/op with 3 allocs before this work).
|
||||
|
||||
func BenchmarkRecordQuotaBucket(b *testing.B) {
|
||||
s := NewStats(0)
|
||||
r := Req{Time: time.Now().UnixMilli(), Key: "keyABC", Model: "deepseek-v4-flash",
|
||||
Source: "deepseek", Prompt: 800, Compl: 200, OK: true, Status: 200, Type: "chat"}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.Record(r)
|
||||
}
|
||||
}
|
||||
|
||||
// A key that has been busy for the whole 40-day retention.
|
||||
func BenchmarkRecordQuotaBucketFullRetention(b *testing.B) {
|
||||
s := NewStats(0)
|
||||
nowH := time.Now().Unix() / 3600
|
||||
for h := int64(0); h < quotaRetentionHours; h++ {
|
||||
s.Record(Req{Time: (nowH - h) * 3600 * 1000, Key: "keyABC", Model: "m1",
|
||||
Source: "deepseek", Prompt: 100, Compl: 100, OK: true, Status: 200})
|
||||
}
|
||||
r := Req{Time: time.Now().UnixMilli(), Key: "keyABC", Model: "m1", Source: "deepseek",
|
||||
Prompt: 100, Compl: 100, OK: true, Status: 200, Type: "chat"}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.Record(r)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole per-request quota check: total tokens + request count.
|
||||
func BenchmarkQuotaCheckHourly(b *testing.B) {
|
||||
s := NewStats(0)
|
||||
for h := 0; h < 24; h++ {
|
||||
s.Record(Req{Time: time.Now().Add(-time.Duration(h) * time.Hour).UnixMilli(),
|
||||
Key: "keyABC", Model: "m1", Source: "deepseek", Prompt: 100, Compl: 100, OK: true, Status: 200})
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.KeyWindowTokens("keyABC", 3600)
|
||||
s.KeyWindowReqs("keyABC", 3600)
|
||||
}
|
||||
}
|
||||
|
||||
// A 30-day window has to visit 720 hour buckets. This is the worst case the
|
||||
// quota feature puts on the request path.
|
||||
func BenchmarkQuotaCheckMonthly(b *testing.B) {
|
||||
s := NewStats(0)
|
||||
nowH := time.Now().Unix() / 3600
|
||||
for h := int64(0); h < 720; h++ {
|
||||
s.Record(Req{Time: (nowH - h) * 3600 * 1000, Key: "keyABC", Model: "m1",
|
||||
Prompt: 100, Compl: 100, OK: true, Status: 200})
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.KeyWindowTokens("keyABC", 30*24*3600)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSumBucketsMatchesFullScan is the correctness guard for the optimization
|
||||
// that made the quota check proportional to the window instead of to the whole
|
||||
// retention: the windowed sum must agree with a full scan for every bucket
|
||||
// placement, or a quota would silently start letting traffic through. It
|
||||
// caught a real off-by-one (floor instead of ceil on the first hour).
|
||||
func TestSumBucketsMatchesFullScan(t *testing.T) {
|
||||
const hour = 3600
|
||||
base := int64(1_700_000_000) / hour * hour
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
for trial := 0; trial < 400; trial++ {
|
||||
hm := map[int64]int64{}
|
||||
for i, n := 0, rng.Intn(40)+1; i < n; i++ {
|
||||
h := base/hour - int64(rng.Intn(1200))
|
||||
hm[h] += int64(rng.Intn(1000)) + 1
|
||||
}
|
||||
now := base + int64(rng.Intn(hour))
|
||||
for _, sec := range []int64{3600, 2 * 3600, 6 * 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600} {
|
||||
var want int64
|
||||
cut := now - sec
|
||||
for h, v := range hm {
|
||||
if h*hour >= cut {
|
||||
want += v
|
||||
}
|
||||
}
|
||||
if got := sumBuckets(hm, now, sec); got != want {
|
||||
t.Fatalf("trial %d sec=%d: sumBuckets=%d, full scan=%d", trial, sec, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pinned bucket is maintained only for (key, model) pairs whose quota pins
|
||||
// a source. Doing it unconditionally doubled the bucket count — measured at
|
||||
// 26.7 MB for 20 keys x 8 models x 3 sources at full retention, against a
|
||||
// documented ~32 MB total memory budget — to serve a lookup nobody performs.
|
||||
func TestPinnedBucketNotMaintainedUnlessWanted(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
now := time.Now().UnixMilli()
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Source: "srcX", Prompt: 100, Compl: 100, OK: true, Status: 200})
|
||||
}
|
||||
s.mu.Lock()
|
||||
_, pinned := s.keyModelHour["keyA"]["srcX::m1"]
|
||||
_, bare := s.keyModelHour["keyA"]["m1"]
|
||||
s.mu.Unlock()
|
||||
if pinned {
|
||||
t.Error("a pinned bucket exists before any quota read the pin — memory regression")
|
||||
}
|
||||
if !bare {
|
||||
t.Error("the bare-model bucket must always exist; an unpinned quota depends on it")
|
||||
}
|
||||
}
|
||||
@ -1,225 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
)
|
||||
|
||||
// ---- data layer: per-key isolation ----
|
||||
|
||||
func TestKeyWindowTokensIsolatesKeys(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
now := time.Now().UnixMilli()
|
||||
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Prompt: 500, Compl: 500, OK: true, Status: 200})
|
||||
s.Record(Req{Time: now, Key: "keyB", Model: "m1", Prompt: 500, Compl: 500, OK: true, Status: 200})
|
||||
|
||||
if got := s.KeyWindowModelTokens("keyA", "m1", "", 3600); got != 1000 {
|
||||
t.Errorf("keyA model tokens = %d, want 1000 (its own usage only)", got)
|
||||
}
|
||||
if got := s.KeyWindowModelTokens("keyB", "m1", "", 3600); got != 1000 {
|
||||
t.Errorf("keyB model tokens = %d, want 1000", got)
|
||||
}
|
||||
// the key-blind bucket stays global on purpose: it backs the AUTO slot
|
||||
// quota, which limits the whole gateway, not one key.
|
||||
if got := s.WindowTokens("m1", "", 3600); got != 2000 {
|
||||
t.Errorf("WindowTokens = %d, want 2000 (global per-model total must be unchanged)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A source pin scopes the cap to that one upstream. The pinned bucket is
|
||||
// maintained lazily (it is only read by quotas that actually pin a source), so
|
||||
// usage recorded before anything queried the pin is not attributed to it.
|
||||
func TestKeyWindowTokensSeparatesSourcePin(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
now := time.Now().UnixMilli()
|
||||
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Source: "srcX", Prompt: 100, Compl: 100, OK: true, Status: 200}) // 200 tok
|
||||
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Source: "srcY", Prompt: 200, Compl: 200, OK: true, Status: 200}) // 400 tok
|
||||
|
||||
// The unpinned bucket always counts — it is what a scope entry without a
|
||||
// source pin reads. 200 + 400.
|
||||
if got := s.KeyWindowModelTokens("keyA", "m1", "", 3600); got != 600 {
|
||||
t.Errorf("unpinned model tokens = %d, want 600 (both sources)", got)
|
||||
}
|
||||
|
||||
// Reading a pin opts this (key, model) into pinned accounting.
|
||||
if got := s.KeyWindowModelTokens("keyA", "m1", "srcX", 3600); got != 0 {
|
||||
t.Errorf("srcX pin before opt-in = %d, want 0 (the lazy bucket has not accrued yet)", got)
|
||||
}
|
||||
|
||||
// From now on both pins accrue.
|
||||
more := now + 1
|
||||
s.Record(Req{Time: more, Key: "keyA", Model: "m1", Source: "srcX", Prompt: 10, Compl: 10, OK: true, Status: 200}) // 20 tok
|
||||
s.Record(Req{Time: more, Key: "keyA", Model: "m1", Source: "srcY", Prompt: 30, Compl: 30, OK: true, Status: 200}) // 60 tok
|
||||
if got := s.KeyWindowModelTokens("keyA", "m1", "srcX", 3600); got != 20 {
|
||||
t.Errorf("srcX-pinned tokens after opt-in = %d, want 20", got)
|
||||
}
|
||||
if got := s.KeyWindowModelTokens("keyA", "m1", "srcY", 3600); got != 60 {
|
||||
t.Errorf("srcY-pinned tokens = %d, want 60 (a pin read for one source must not blind the other)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the shape of every real chat record: Source is always populated.
|
||||
// The unpinned bucket must still see the tokens, or a per-model quota without
|
||||
// a source pin reads an empty bucket and never trips.
|
||||
func TestKeyWindowModelTokensUnpinnedSeesSourcedTraffic(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
s.Record(Req{Time: time.Now().UnixMilli(), Key: "keyA", Model: "m1", Source: "deepseek",
|
||||
Prompt: 1000, Compl: 1000, OK: true, Status: 200})
|
||||
|
||||
if got := s.KeyWindowModelTokens("keyA", "m1", "", 3600); got != 2000 {
|
||||
t.Errorf("unpinned tokens = %d, want 2000 — an unpinned per-model quota would never trip otherwise", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyWindowRespectsResetWindow(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
old := time.Now().Add(-30 * 24 * time.Hour).UnixMilli()
|
||||
s.Record(Req{Time: old, Key: "keyA", Model: "m1", Prompt: 100, Compl: 100, OK: true, Status: 200})
|
||||
s.Record(Req{Time: time.Now().UnixMilli(), Key: "keyA", Model: "m1", Prompt: 5, Compl: 5, OK: true, Status: 200})
|
||||
|
||||
if got := s.KeyWindowTokens("keyA", 3600); got != 10 {
|
||||
t.Errorf("1h window = %d, want 10 (30-day-old usage must not count)", got)
|
||||
}
|
||||
if got := s.KeyWindowTokens("keyA", 0); got != 210 {
|
||||
t.Errorf("all-time total = %d, want 210", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyWindowReqsCountsEveryRequest(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
now := time.Now().UnixMilli()
|
||||
// a failed request and an image request both count: a client that loops on
|
||||
// failures must still burn its request quota
|
||||
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Type: "chat", OK: true, Status: 200})
|
||||
s.Record(Req{Time: now, Key: "keyA", Model: "m1", Type: "chat", OK: false, Status: 500})
|
||||
s.Record(Req{Time: now, Key: "keyA", Model: "img", Type: "image", OK: true, Status: 200})
|
||||
s.Record(Req{Time: now, Key: "keyB", Model: "m1", Type: "chat", OK: true, Status: 200})
|
||||
|
||||
if got := s.KeyWindowReqs("keyA", 3600); got != 3 {
|
||||
t.Errorf("keyA reqs = %d, want 3 (chat ok + chat fail + image)", got)
|
||||
}
|
||||
if got := s.KeyWindowReqs("keyB", 3600); got != 1 {
|
||||
t.Errorf("keyB reqs = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyWindowTokensZeroTokensNotCounted(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
now := time.Now().UnixMilli()
|
||||
// a request that reported no usage must not create a bucket entry
|
||||
s.Record(Req{Time: now, Key: "keyA", Model: "", Type: "chat", OK: true, Status: 200})
|
||||
if got := s.KeyWindowTokens("keyA", 3600); got != 0 {
|
||||
t.Errorf("tokens = %d, want 0", got)
|
||||
}
|
||||
if got := s.KeyWindowReqs("keyA", 3600); got != 1 {
|
||||
t.Errorf("reqs = %d, want 1 (request still happened)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- reset window arithmetic ----
|
||||
|
||||
func TestAutoSecondsToReset(t *testing.T) {
|
||||
if got := AutoSecondsToReset("", 0); got != 0 {
|
||||
t.Errorf("no period = %d, want 0 (never resets -> no retry hint)", got)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
for _, tc := range []struct {
|
||||
period string
|
||||
hours int64
|
||||
want int64
|
||||
}{
|
||||
{"hour", 0, 3600},
|
||||
{"week", 0, 7 * 24 * 3600},
|
||||
{"month", 0, 30 * 24 * 3600},
|
||||
{"nhour", 6, 6 * 3600},
|
||||
} {
|
||||
got := AutoSecondsToReset(tc.period, tc.hours)
|
||||
if got <= 0 || got > tc.want {
|
||||
t.Errorf("AutoSecondsToReset(%q,%d) = %d, want in (0,%d]", tc.period, tc.hours, got, tc.want)
|
||||
}
|
||||
// must never exceed the window itself
|
||||
if got < tc.want-now%3600 {
|
||||
t.Logf("note: %q hint %ds < remaining-window %ds (rounds to hour boundary)", tc.period, got, tc.want-now%3600)
|
||||
}
|
||||
if got > tc.want {
|
||||
t.Errorf("hint %d exceeds window %d", got, tc.want)
|
||||
}
|
||||
}
|
||||
_ = now
|
||||
}
|
||||
|
||||
func TestAutoSecondsToResetNeverExceedsWindow(t *testing.T) {
|
||||
// "nhour" with a tiny window must not hand out a longer wait than the
|
||||
// window itself (which would stall a client past its own reset)
|
||||
for hours := int64(1); hours <= 48; hours++ {
|
||||
got := AutoSecondsToReset("nhour", hours)
|
||||
if got <= 0 || got > hours*3600 {
|
||||
t.Errorf("nhour/%d = %d, want in (0,%d]", hours, got, hours*3600)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- config validation ----
|
||||
|
||||
func TestKeyQuotaValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
q config.KeyQuota
|
||||
wantErr bool
|
||||
}{
|
||||
{"unlimited", config.KeyQuota{}, false},
|
||||
{"tokens hourly", config.KeyQuota{TokenQuota: 1000, Period: "hour"}, false},
|
||||
{"tokens n-hour", config.KeyQuota{TokenQuota: 1000, Period: "nhour", Hours: 6}, false},
|
||||
{"reqs weekly", config.KeyQuota{ReqQuota: 100, Period: "week"}, false},
|
||||
{"no period never resets", config.KeyQuota{TokenQuota: 1000}, false},
|
||||
{"negative tokens", config.KeyQuota{TokenQuota: -1}, true},
|
||||
{"negative reqs", config.KeyQuota{ReqQuota: -1}, true},
|
||||
{"negative hours", config.KeyQuota{TokenQuota: 5, Hours: -1}, true},
|
||||
{"typo period becomes all-time if accepted", config.KeyQuota{TokenQuota: 5, Period: "houre"}, true},
|
||||
{"nhour without hours", config.KeyQuota{TokenQuota: 5, Period: "nhour"}, true},
|
||||
{"nhour with 0 hours", config.KeyQuota{TokenQuota: 5, Period: "nhour", Hours: 0}, true},
|
||||
{"junk period with only req quota", config.KeyQuota{ReqQuota: 5, Period: "daily"}, true},
|
||||
{"junk period with no caps is irrelevant", config.KeyQuota{Period: "daily"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := tc.q.Validate()
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("%s: expected error, got nil", tc.name)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("%s: unexpected error: %v", tc.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRole(t *testing.T) {
|
||||
if got := config.NormalizeRole(""); got != "user" {
|
||||
t.Errorf("empty role = %q, want user", got)
|
||||
}
|
||||
if got := config.NormalizeRole("admin"); got != "admin" {
|
||||
t.Errorf("admin role = %q, want admin", got)
|
||||
}
|
||||
if got := config.NormalizeRole("root"); got != "user" {
|
||||
t.Errorf("unknown role = %q, want user (never escalate)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Retention must hold for the lazy pinned buckets too, or a long-lived key
|
||||
// would grow without bound.
|
||||
func TestPinnedBucketRespectsRetention(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
_ = s.KeyWindowModelTokens("keyA", "m1", "srcX", 3600) // opt in
|
||||
nowH := time.Now().Unix() / 3600
|
||||
for h := int64(0); h < quotaRetentionHours+50; h++ {
|
||||
s.Record(Req{Time: (nowH - h) * 3600 * 1000, Key: "keyA", Model: "m1", Source: "srcX",
|
||||
Prompt: 10, Compl: 10, OK: true, Status: 200})
|
||||
}
|
||||
s.mu.Lock()
|
||||
n := len(s.keyModelHour["keyA"]["srcX::m1"])
|
||||
s.mu.Unlock()
|
||||
if n > quotaRetentionHours {
|
||||
t.Errorf("pinned bucket holds %d hours, want <= %d", n, quotaRetentionHours)
|
||||
}
|
||||
}
|
||||
@ -1,454 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// quotaGateway builds a gateway with two user keys that differ only in their
|
||||
// configured caps, against one mock upstream that reports 4 tokens per call.
|
||||
func quotaGateway(t *testing.T, keyA, keyB config.GWKey) (*Gateway, *upstreamCtrl) {
|
||||
t.Helper()
|
||||
ctrl := &upstreamCtrl{}
|
||||
up := upstream(t, ctrl)
|
||||
t.Cleanup(up.Close)
|
||||
|
||||
td := t.TempDir()
|
||||
cfgPath := td + "/config.yaml"
|
||||
if err := os.WriteFile(cfgPath, []byte("listen: :0"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: cfgPath,
|
||||
AdapterDir: td + "/adapters",
|
||||
RuntimeFile: td + "/runtime.json",
|
||||
Keys: []config.GWKey{keyA, keyB},
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: up.URL, Adapter: "openai",
|
||||
Models: []config.Model{{ID: "m1"}},
|
||||
}},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g, ctrl
|
||||
}
|
||||
|
||||
// chatAs issues a chat completion with a specific gateway key and returns the
|
||||
// recorder plus the decoded error code.
|
||||
func chatAs(t *testing.T, g *Gateway, key, model string) (*httptest.ResponseRecorder, string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("POST", "/v1/chat/completions",
|
||||
strings.NewReader(fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}]}`, model)))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rr, req)
|
||||
code := ""
|
||||
if rr.Code != 200 {
|
||||
var e struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &e)
|
||||
code = e.Error.Type
|
||||
}
|
||||
return rr, code
|
||||
}
|
||||
|
||||
// A key whose token budget is spent must be refused, and the refusal must be a
|
||||
// 429 the client can retry after the window rolls over — not a 403 that reads
|
||||
// as "this key may never use this model".
|
||||
func TestKeyTokenQuotaBlocksWithRetryAfter(t *testing.T) {
|
||||
g, _ := quotaGateway(t,
|
||||
config.GWKey{Key: "sk-a", Role: "user",
|
||||
Models: []config.ModelScope{{Model: "m1", TokenQuota: 8, Period: "hour"}}},
|
||||
config.GWKey{Key: "sk-b", Role: "user", Name: "b"},
|
||||
)
|
||||
// 4 tokens per call, budget 8 -> the third call crosses it
|
||||
for i := 1; i <= 2; i++ {
|
||||
rr, _ := chatAs(t, g, "sk-a", "m1")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("call %d: want 200, got %d (%s)", i, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
rr, code := chatAs(t, g, "sk-a", "m1")
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("third call: want 429, got %d (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if code != "rate_limit_exceeded" {
|
||||
t.Errorf("error code = %q, want rate_limit_exceeded", code)
|
||||
}
|
||||
if ra := rr.Header().Get("Retry-After"); ra == "" {
|
||||
t.Error("429 must carry Retry-After so a client knows when to come back")
|
||||
} else if n := mustAtoi(t, ra); n <= 0 || n > 3600 {
|
||||
t.Errorf("Retry-After = %q, want a positive value within the 1h window", ra)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "quota") {
|
||||
t.Errorf("body should name the quota: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The quota is per key: exhausting one key's budget must not affect another
|
||||
// key that is allowed the same model.
|
||||
func TestKeyTokenQuotaIsIsolatedPerKey(t *testing.T) {
|
||||
g, _ := quotaGateway(t,
|
||||
config.GWKey{Key: "sk-a", Role: "user",
|
||||
Models: []config.ModelScope{{Model: "m1", TokenQuota: 4, Period: "hour"}}},
|
||||
config.GWKey{Key: "sk-b", Role: "user",
|
||||
Models: []config.ModelScope{{Model: "m1", TokenQuota: 1000, Period: "hour"}}},
|
||||
)
|
||||
rr, _ := chatAs(t, g, "sk-a", "m1")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("keyA first call: want 200, got %d", rr.Code)
|
||||
}
|
||||
if rr, code := chatAs(t, g, "sk-a", "m1"); rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("keyA second call: want 429, got %d (%s)", rr.Code, code)
|
||||
}
|
||||
// keyB has its own budget and must still be served
|
||||
if rr, _ := chatAs(t, g, "sk-b", "m1"); rr.Code != 200 {
|
||||
t.Fatalf("keyB must be unaffected by keyA's exhausted quota, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A per-model quota inside the scope must also be per key: one key burning a
|
||||
// shared model budget must not lock the other key out.
|
||||
func TestScopeModelTokenQuotaIsolatedPerKey(t *testing.T) {
|
||||
g, _ := quotaGateway(t,
|
||||
config.GWKey{Key: "sk-a", Role: "user", Models: []config.ModelScope{
|
||||
{Model: "m1", TokenQuota: 4, Period: "hour"},
|
||||
}},
|
||||
config.GWKey{Key: "sk-b", Role: "user", Models: []config.ModelScope{
|
||||
{Model: "m1", TokenQuota: 4, Period: "hour"},
|
||||
}},
|
||||
)
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != 200 {
|
||||
t.Fatalf("keyA first call: want 200, got %d", rr.Code)
|
||||
}
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("keyA second call: want 429 (its own model quota), got %d", rr.Code)
|
||||
}
|
||||
// keyB shares the model but not the counter — it must be served twice too
|
||||
if rr, _ := chatAs(t, g, "sk-b", "m1"); rr.Code != 200 {
|
||||
t.Fatalf("keyB first call: want 200, got %d", rr.Code)
|
||||
}
|
||||
if rr, _ := chatAs(t, g, "sk-b", "m1"); rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("keyB second call: want 429, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// An admin key is never capped: a budget set on an admin key must not lock the
|
||||
// operator out of the gateway they administer.
|
||||
func TestAdminKeyIsNeverQuotaCapped(t *testing.T) {
|
||||
g, _ := quotaGateway(t,
|
||||
config.GWKey{Key: "sk-admin", Role: "admin",
|
||||
Models: []config.ModelScope{{Model: "m1", TokenQuota: 1, ReqQuota: 1, Period: "hour"}}},
|
||||
config.GWKey{Key: "sk-b", Role: "user"},
|
||||
)
|
||||
for i := 1; i <= 3; i++ {
|
||||
if rr, _ := chatAs(t, g, "sk-admin", "m1"); rr.Code != 200 {
|
||||
t.Fatalf("admin call %d: want 200 (admin keys are uncapped), got %d", i, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A request quota caps sustained volume; RPM-style bursts are not its job but
|
||||
// the count must still stop the key.
|
||||
func TestKeyRequestQuotaBlocks(t *testing.T) {
|
||||
g, ctrl := quotaGateway(t,
|
||||
config.GWKey{Key: "sk-a", Role: "user",
|
||||
Models: []config.ModelScope{{Model: "m1", ReqQuota: 2, Period: "hour"}}},
|
||||
config.GWKey{Key: "sk-b", Role: "user"},
|
||||
)
|
||||
for i := 1; i <= 2; i++ {
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != 200 {
|
||||
t.Fatalf("call %d: want 200, got %d", i, rr.Code)
|
||||
}
|
||||
}
|
||||
rr, code := chatAs(t, g, "sk-a", "m1")
|
||||
if rr.Code != http.StatusTooManyRequests || code != "rate_limit_exceeded" {
|
||||
t.Fatalf("third call: want 429/rate_limit_exceeded, got %d/%s", rr.Code, code)
|
||||
}
|
||||
if ctrl.hits != 2 {
|
||||
t.Errorf("upstream saw %d calls, want 2 (a refused request must not reach upstream)", ctrl.hits)
|
||||
}
|
||||
}
|
||||
|
||||
// A model outside the scope is still 403, not 429: retrying cannot help.
|
||||
func TestModelOutsideScopeStaysForbidden(t *testing.T) {
|
||||
g, _ := quotaGateway(t,
|
||||
config.GWKey{Key: "sk-a", Role: "user",
|
||||
Models: []config.ModelScope{{Model: "other-model", TokenQuota: 1000, Period: "hour"}}},
|
||||
config.GWKey{Key: "sk-b", Role: "user"},
|
||||
)
|
||||
rr, code := chatAs(t, g, "sk-a", "m1")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("want 403, got %d (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if code != "model_not_allowed" {
|
||||
t.Errorf("error code = %q, want model_not_allowed", code)
|
||||
}
|
||||
if rr.Header().Get("Retry-After") != "" {
|
||||
t.Error("a 403 must not advertise a retry time")
|
||||
}
|
||||
}
|
||||
|
||||
// An unlimited key (no caps at all) is never rejected — the default config
|
||||
// must keep working exactly as before.
|
||||
func TestKeyWithoutCapsIsUnlimited(t *testing.T) {
|
||||
g, _ := quotaGateway(t,
|
||||
config.GWKey{Key: "sk-a", Role: "user", Models: []config.ModelScope{{Model: "m1"}}},
|
||||
config.GWKey{Key: "sk-b", Role: "user"},
|
||||
)
|
||||
for i := 1; i <= 5; i++ {
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != 200 {
|
||||
t.Fatalf("call %d: want 200 (no caps = unlimited), got %d", i, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustAtoi(t *testing.T, s string) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
|
||||
t.Fatalf("Retry-After %q is not an integer: %v", s, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// quotaCtx builds a request context carrying a gateway key, as the auth
|
||||
// middleware would.
|
||||
func quotaCtx(t *testing.T, g *Gateway, key string) context.Context {
|
||||
t.Helper()
|
||||
return withAuth(context.Background(), key, "user")
|
||||
}
|
||||
|
||||
func nowMSOffset(sec int64) int64 {
|
||||
return time.Now().Add(time.Duration(sec) * time.Second).UnixMilli()
|
||||
}
|
||||
|
||||
// The key-wide quota and the AUTO slot quota are different limits with
|
||||
// different scopes: the slot quota is gateway-wide (a shared upstream budget),
|
||||
// the key quota belongs to one caller. When both are exhausted the caller must
|
||||
// see the KEY quota, because that is the one it can act on — the slot quota
|
||||
// would otherwise be reported as "no capacity", which reads like an outage.
|
||||
func TestKeyQuotaWinsOverSlotQuota(t *testing.T) {
|
||||
up := upstream(t, &upstreamCtrl{})
|
||||
defer up.Close()
|
||||
g := newQuotaGW(t, up.URL,
|
||||
config.GWKey{Key: "sk-a", Role: "user",
|
||||
Models: []config.ModelScope{{Model: "AUTO", TokenQuota: 4, Period: "hour"}}})
|
||||
ctx := quotaCtx(t, g, "sk-a")
|
||||
|
||||
// exhaust the key first
|
||||
g.stats.Record(Req{Time: time.Now().UnixMilli(), Key: keyID("sk-a"), Model: "m1",
|
||||
Source: "up", Prompt: 100, Compl: 100, OK: true, Status: 200})
|
||||
rr, code := chatAs(t, g, "sk-a", "AUTO")
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("want 429 from the key quota, got %d (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if code != "rate_limit_exceeded" {
|
||||
t.Errorf("code = %q, want rate_limit_exceeded", code)
|
||||
}
|
||||
_ = ctx
|
||||
}
|
||||
|
||||
// A key with NO caps must never be blocked by the slot quota check reaching it
|
||||
// through the scope path: scopeTokens on an uncapped key returns 0 usage and
|
||||
// the limit check must treat that as "no cap", not "exhausted".
|
||||
func TestUncappedKeyNeverBlockedByEmptyScope(t *testing.T) {
|
||||
up := upstream(t, &upstreamCtrl{})
|
||||
defer up.Close()
|
||||
g := newQuotaGW(t, up.URL,
|
||||
config.GWKey{Key: "sk-a", Role: "user", Models: []config.ModelScope{{Model: "AUTO"}}})
|
||||
for i := 0; i < 5; i++ {
|
||||
if rr, _ := chatAs(t, g, "sk-a", "AUTO"); rr.Code != 200 {
|
||||
t.Fatalf("call %d: want 200 (AUTO scope with no quota), got %d (%s)", i, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newQuotaGW builds a gateway with one mock upstream serving model m1 and the
|
||||
// given keys.
|
||||
func newQuotaGW(t *testing.T, upURL string, keys ...config.GWKey) *Gateway {
|
||||
t.Helper()
|
||||
td := t.TempDir()
|
||||
cfgPath := filepath.Join(td, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte("listen: :0"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: cfgPath,
|
||||
AdapterDir: filepath.Join(td, "adapters"),
|
||||
RuntimeFile: filepath.Join(td, "runtime.json"),
|
||||
Keys: keys,
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: upURL, Adapter: "openai",
|
||||
Models: []config.Model{
|
||||
{ID: "m1"},
|
||||
{ID: "m2"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 内核不再 seedAuto 自动播种 AUTO 链(空链=503 是刻意的)。本夹具的用例
|
||||
// 全都在验"AUTO 请求会不会被 key 配额挡住",必须显式建一条链 ——
|
||||
// 否则每个用例都先撞 503,验的就不是配额了。
|
||||
if len(cfg.Auto) == 0 {
|
||||
cfg.Auto = []config.ModelScope{
|
||||
{Model: "m1", Source: "up", Tier: 1},
|
||||
{Model: "m2", Source: "up", Tier: 2},
|
||||
}
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
secrets := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
secrets = append(secrets, k.Key)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// The core of per-model quotas: a model that runs out of budget must stop
|
||||
// being served on its own, while every other model on the SAME key keeps
|
||||
// working. A key-wide total would fail this — it would block m2 because m1 was
|
||||
// capped, which is exactly the coupling this design removes.
|
||||
func TestOneModelsQuotaDoesNotBlockAnother(t *testing.T) {
|
||||
up := upstream(t, &upstreamCtrl{})
|
||||
defer up.Close()
|
||||
td := t.TempDir()
|
||||
cfgPath := td + "/config.yaml"
|
||||
if err := os.WriteFile(cfgPath, []byte("listen: :0"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: cfgPath, AdapterDir: filepath.Join(td, "adapters"),
|
||||
RuntimeFile: filepath.Join(td, "runtime.json"),
|
||||
Keys: []config.GWKey{{
|
||||
Key: "sk-a", Role: "user", Name: "two-models",
|
||||
Models: []config.ModelScope{
|
||||
{Model: "m1", TokenQuota: 4, Period: "hour"},
|
||||
{Model: "m2", TokenQuota: 1000, Period: "hour"},
|
||||
},
|
||||
}},
|
||||
Sources: []config.Source{{
|
||||
Name: "up", BaseURL: up.URL, Adapter: "openai",
|
||||
Models: []config.Model{
|
||||
{ID: "m1"},
|
||||
{ID: "m2"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
|
||||
// Spend on m2 FIRST. Without this the two designs are
|
||||
// indistinguishable: a key-wide counter and m1's own counter would both
|
||||
// read 0 before m1 is used, so the test would pass either way (it did —
|
||||
// see the commit that rewrote it).
|
||||
for i := 1; i <= 3; i++ {
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m2"); rr.Code != 200 {
|
||||
t.Fatalf("m2 priming call %d: want 200, got %d", i, rr.Code)
|
||||
}
|
||||
}
|
||||
// m1's budget is 4 and each call costs 4, so the key-wide total (m2+m1) is
|
||||
// already 12 when m1 starts: a key-wide cap would refuse m1 immediately.
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != 200 {
|
||||
t.Fatalf("m1 must be served on its own budget (m2's spend is not its problem), got %d (%s)",
|
||||
rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr, code := chatAs(t, g, "sk-a", "m1"); rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("m1 second call: want 429, got %d (%s)", rr.Code, code)
|
||||
}
|
||||
// m2 must keep working
|
||||
for i := 1; i <= 3; i++ {
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m2"); rr.Code != 200 {
|
||||
t.Fatalf("m2 call %d must be served while m1 is capped, got %d (%s)", i, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
// and the message must name m1, not the key
|
||||
rr, _ := chatAs(t, g, "sk-a", "m1")
|
||||
if !strings.Contains(rr.Body.String(), "m1") {
|
||||
t.Errorf("rejection should name the capped model: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A model with no quota on it is never blocked by a sibling's cap, and an
|
||||
// uncapped key is never blocked at all.
|
||||
func TestUncappedModelNeverBlocked(t *testing.T) {
|
||||
up := upstream(t, &upstreamCtrl{})
|
||||
defer up.Close()
|
||||
g := newQuotaGW(t, up.URL,
|
||||
config.GWKey{Key: "sk-a", Role: "user", Models: []config.ModelScope{
|
||||
{Model: "m1", TokenQuota: 1, Period: "hour"},
|
||||
{Model: "m2"},
|
||||
}})
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != 200 {
|
||||
t.Fatalf("m1 first: want 200, got %d", rr.Code)
|
||||
}
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m1"); rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("m1 second: want 429, got %d", rr.Code)
|
||||
}
|
||||
for i := 1; i <= 4; i++ {
|
||||
if rr, _ := chatAs(t, g, "sk-a", "m2"); rr.Code != 200 {
|
||||
t.Fatalf("m2 (uncapped) call %d: want 200, got %d", i, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An admin key is never capped even when its scopes carry budgets: a cap that
|
||||
// locked the operator out would be unrecoverable through the UI.
|
||||
func TestAdminKeyScopesAreNotEnforced(t *testing.T) {
|
||||
up := upstream(t, &upstreamCtrl{})
|
||||
defer up.Close()
|
||||
g := newQuotaGW(t, up.URL,
|
||||
config.GWKey{Key: "sk-admin", Role: "admin", Models: []config.ModelScope{
|
||||
{Model: "m1", TokenQuota: 1, ReqQuota: 1, Period: "hour"},
|
||||
}})
|
||||
for i := 1; i <= 4; i++ {
|
||||
if rr, _ := chatAs(t, g, "sk-admin", "m1"); rr.Code != 200 {
|
||||
t.Fatalf("admin call %d: want 200 (admin scopes are not enforced), got %d", i, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/scheduler"
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// handleKeysAPI manages gateway keys: GET /api/keys (admin: all keys),
|
||||
@ -18,22 +17,10 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path = strings.Trim(path, "/")
|
||||
role := reqRole(r.Context())
|
||||
|
||||
// /api/keys/me/auto — a user's own chain, readable without admin rights.
|
||||
// Must be matched before the generic {key}/auto case below so it does not
|
||||
// ask admin rights of a plain user asking about their own chain.
|
||||
if path == "me/auto" {
|
||||
g.handleKeyMeAuto(w, r)
|
||||
return
|
||||
}
|
||||
if path == "me" {
|
||||
g.handleKeyMe(w, r)
|
||||
return
|
||||
}
|
||||
// /api/keys/{key}/auto — the per-key AUTO chain editor.
|
||||
if i := strings.LastIndex(path, "/auto"); i > 0 && path[i+len("/auto"):] == "" {
|
||||
g.handleKeyAutoAPI(w, r, path[:i])
|
||||
return
|
||||
}
|
||||
if role != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
@ -61,7 +48,13 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
body.Role = config.NormalizeRole(body.Role)
|
||||
if body.Role == "" {
|
||||
body.Role = "user"
|
||||
}
|
||||
if body.Role != "admin" && body.Role != "user" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "role must be admin or user")
|
||||
return
|
||||
}
|
||||
rec, err := g.core.CreateKey(body.Name, body.Role, body.Models, body.Note)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
||||
@ -83,9 +76,6 @@ func (g *Gateway) handleKeysAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Each scope entry carries its own token/request caps, so replacing the
|
||||
// scope replaces the budgets with it — there is no separate key-wide
|
||||
// quota that could drift out of sync with the models.
|
||||
rec, err := g.core.UpdateKey(path, body.Name, body.Role, body.Models, body.Note)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
||||
@ -152,101 +142,6 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
|
||||
return rec.Models
|
||||
}
|
||||
|
||||
// handleKeyAutoAPI manages one key's own AUTO chain:
|
||||
// GET /api/keys/{key}/auto returns it, PUT replaces it, DELETE clears the
|
||||
// override so the key falls back to the global chain. Admin only — a user can
|
||||
// inspect their own chain through GET /api/keys/me/auto.
|
||||
//
|
||||
// An empty chain and a cleared chain are deliberately the same state: "inherit
|
||||
// the global chain". That keeps the WebUI's "use global" toggle a plain DELETE
|
||||
// with no sentinel value to carry through config.yaml.
|
||||
func (g *Gateway) handleKeyAutoAPI(w http.ResponseWriter, r *http.Request, key string) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
rec, ok := g.core.FindKey(key)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "key not found")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"key": key,
|
||||
"auto": rec.Auto,
|
||||
"inherits": !rec.HasOwnAuto(),
|
||||
})
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
var body struct {
|
||||
Auto []config.ModelScope `json:"auto"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(body.Auto) == 0 {
|
||||
// Treat an empty PUT as "give up the override" so the endpoint
|
||||
// cannot persist a chain that would 503 with no slots.
|
||||
if err := g.core.SaveKeyAuto(key, nil); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
|
||||
return
|
||||
}
|
||||
if err := g.core.SaveKeyAuto(key, body.Auto); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
||||
return
|
||||
}
|
||||
// Report whether the chain actually resolved to usable slots. An
|
||||
// admin who typed a model that no longer exists should learn it here,
|
||||
// not from the user's next 503.
|
||||
chain, _ := g.core.AutoChainFor(key)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true, "inherits": false, "slots": chainSlots(chain),
|
||||
})
|
||||
case http.MethodDelete:
|
||||
if err := g.core.SaveKeyAuto(key, nil); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "key_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "inherits": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
|
||||
// chainSlots counts the usable slots in a chain, for API feedback after an
|
||||
// edit. A chain that compiled to zero slots is reported so the caller can warn
|
||||
// instead of letting the next request fail with no_provider.
|
||||
func chainSlots(ch *scheduler.Chain) int {
|
||||
if ch == nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, tn := range ch.Tiers {
|
||||
n += len(tn.Slots)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// handleKeyMeAuto lets a user read their own AUTO chain without admin rights.
|
||||
func (g *Gateway) handleKeyMeAuto(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
rec, ok := g.core.FindKey(reqKey(r.Context()))
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "invalid_api_key", "key not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"key": rec.Key, "auto": rec.Auto, "inherits": !rec.HasOwnAuto(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAutoAPI manages the AUTO scheduling slots: GET /api/auto returns the
|
||||
// current rules; PUT /api/auto replaces them (admin only).
|
||||
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@ -1,379 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"modelrouter/internal/provider"
|
||||
"modelrouter/internal/scheduler"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// handleMessages serves the Anthropic Messages protocol (POST /v1/messages).
|
||||
//
|
||||
// The point of this endpoint is NOT to add a second scheduling path. It reuses
|
||||
// everything /v1/chat/completions does — key auth, model scope, per-model
|
||||
// quotas, the AUTO chain, cooldowns, audit records, plugin hooks — and differs
|
||||
// in exactly two places:
|
||||
//
|
||||
// 1. the request body is parsed from Anthropic's shape into the unified format
|
||||
// (messages with content blocks, a top-level `system`, `max_tokens`
|
||||
// mandatory, `thinking` optional);
|
||||
// 2. the response is rendered by the SERVING adapter's serialize_* hooks, so
|
||||
// the client sees Anthropic's shape back.
|
||||
//
|
||||
// The kernel therefore contains no Anthropic field names on this path: parsing
|
||||
// is table-driven, and serialization belongs to the adapter.
|
||||
//
|
||||
// A source whose adapter does not declare serialize_* falls back to the
|
||||
// OpenAI shape, which is what the kernel guarantees for an unconfigured
|
||||
// deployment. That asymmetry is deliberate (see serializer.go): an Anthropic
|
||||
// client talking to a source that cannot speak Anthropic gets a clear
|
||||
// OpenAI-shaped answer rather than a malformed Anthropic one.
|
||||
func (g *Gateway) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "read body: "+err.Error())
|
||||
return
|
||||
}
|
||||
// INGRESS: the kernel's built-in Anthropic parse is the FALLBACK (R5). If an
|
||||
// adapter has CLAIMED this ingress path (adapter.ingress == "/v1/messages"),
|
||||
// the kernel YIELDS: parsing is done by that adapter's deserialize_request
|
||||
// hook, so Anthropic protocol iteration lives in anthropic.lua, not here.
|
||||
// No adapter claims it → the built-in parser below runs (unconfigured
|
||||
// deployments keep working). This is the inbound dual of serialize_*.
|
||||
req, handled, perr := g.parseViaIngress(r.URL.Path, string(body), g.core.DefaultModel())
|
||||
if perr != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", perr.Error())
|
||||
return
|
||||
}
|
||||
if !handled {
|
||||
var raw anthropicRequest
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
req, err = raw.toUnified(g.core.DefaultModel())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
model := req.Model
|
||||
ctx := r.Context()
|
||||
g.fireStart(ctx, &chatRequest{Messages: req.Messages, Tools: nil},
|
||||
chatKind(req.Stream), model, len(req.Messages), 0)
|
||||
|
||||
if isAuto(model) {
|
||||
chain, _ := g.core.AutoChainFor(reqKey(ctx))
|
||||
if chain == nil || len(chain.Tiers) == 0 {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_provider", "no auto slot configured")
|
||||
return
|
||||
}
|
||||
if q := g.checkQuota(ctx, "AUTO"); q != nil {
|
||||
g.writeReject(w, q)
|
||||
return
|
||||
}
|
||||
g.serveUnified(ctx, w, chain, req, nil)
|
||||
return
|
||||
}
|
||||
if allow := g.allowedModels(ctx); allow != nil && !g.hasScopeModel(allow, model) {
|
||||
writeError(w, http.StatusForbidden, "model_not_allowed",
|
||||
"model \""+model+"\" is not allowed for this key")
|
||||
return
|
||||
}
|
||||
cands, effective := g.resolveCands(ctx, &chatRequest{Model: model})
|
||||
if len(cands) == 0 {
|
||||
writeError(w, http.StatusNotFound, "model_not_found",
|
||||
"model \""+model+"\" is not configured")
|
||||
return
|
||||
}
|
||||
if effective == "" {
|
||||
effective = firstModel(cands[0])
|
||||
}
|
||||
if q := g.checkQuota(ctx, effective); q != nil {
|
||||
g.writeReject(w, q)
|
||||
return
|
||||
}
|
||||
g.serveUnified(ctx, w, nil, req, &directRoute{cands: cands, effective: effective})
|
||||
}
|
||||
|
||||
// directRoute carries the pre-resolved candidate list for a model-pinned
|
||||
// request, so serveUnified can run both routes (direct / AUTO) from one body.
|
||||
type directRoute struct {
|
||||
cands []*provider.Provider
|
||||
effective string
|
||||
}
|
||||
|
||||
// serveUnified runs the request through the scheduler and writes the reply via
|
||||
// the serving adapter's serializer. It is the shared tail of handleChat and
|
||||
// handleMessages: by this point every gate (auth, scope, quota) has already run,
|
||||
// so the only remaining questions are "which source serves it" and "what shape
|
||||
// does the client get".
|
||||
func (g *Gateway) serveUnified(ctx context.Context, w http.ResponseWriter, chain *scheduler.Chain, req *types.ChatRequest, direct *directRoute) {
|
||||
rec := &Req{Key: keyID(reqKey(ctx)), Type: "chat", OK: false}
|
||||
if direct != nil {
|
||||
rec.Model = direct.effective
|
||||
rec.Source = firstSource(direct.cands)
|
||||
} else {
|
||||
rec.Type = "stream"
|
||||
}
|
||||
if req.Stream {
|
||||
rec.Type = "stream"
|
||||
}
|
||||
|
||||
done := g.stats.Begin()
|
||||
defer done()
|
||||
|
||||
var (
|
||||
resp *types.UnifiedResponse
|
||||
chunks <-chan types.UnifiedChunk
|
||||
usedSrc, usedModel string
|
||||
err error
|
||||
)
|
||||
t0 := time.Now()
|
||||
if direct != nil {
|
||||
cands := toScheduler(direct.cands)
|
||||
if req.Stream {
|
||||
chunks, usedSrc, usedModel, err = g.core.Scheduler().ChatStream(ctx, cands, req)
|
||||
} else {
|
||||
resp, usedSrc, usedModel, err = g.core.Scheduler().Chat(ctx, cands, req)
|
||||
}
|
||||
} else {
|
||||
if req.Stream {
|
||||
chunks, usedSrc, usedModel, err = g.core.Scheduler().ChainChatStream(ctx, chain, req, nil, nil)
|
||||
} else {
|
||||
resp, usedSrc, usedModel, err = g.core.Scheduler().ChainChat(ctx, chain, req, nil, nil)
|
||||
}
|
||||
}
|
||||
rec.LatMs = time.Since(t0).Milliseconds()
|
||||
if err != nil {
|
||||
g.failChat(w, rec, err)
|
||||
g.writeRec(rec)
|
||||
return
|
||||
}
|
||||
rec.OK = true
|
||||
rec.Status = http.StatusOK
|
||||
if usedModel != "" {
|
||||
rec.Model = usedModel
|
||||
}
|
||||
rec.Source = usedSrc
|
||||
ser := g.serializerFor(usedSrc, directProviderList(direct))
|
||||
|
||||
if chunks != nil {
|
||||
g.fireRouted(ctx, "stream", usedSrc, usedModel, tierForRoute(direct), true)
|
||||
rec.Prompt = estimatePromptTokens(req)
|
||||
g.pumpStream(w, ser, rec, chunks, modelLabel(req, usedModel), t0)
|
||||
return
|
||||
}
|
||||
recordChatUsage(rec, req, resp)
|
||||
g.fireRouted(ctx, "chat", usedSrc, usedModel, tierForRoute(direct), false)
|
||||
rec.FirstByteMs = rec.LatMs
|
||||
g.writeRec(rec)
|
||||
writeChatCompletion(w, ser, resp, modelLabel(req, usedModel))
|
||||
}
|
||||
|
||||
// ---- Anthropic request shape (in-bound only; the kernel holds no outbound
|
||||
// Anthropic field names) ----
|
||||
|
||||
// anthropicRequest is the client-facing parse target for POST /v1/messages.
|
||||
//
|
||||
// Only the fields the gateway must understand to schedule and account for a
|
||||
// request appear here. Anything else the client sent is preserved verbatim in
|
||||
// raw so an adapter can read it (the same "kernel does not interpret" rule that
|
||||
// governs tools).
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
System json.RawMessage `json:"system,omitempty"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
TopK int `json:"top_k,omitempty"`
|
||||
StopSeqs []string `json:"stop_sequences,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Thinking *types.ThinkingConfig `json:"thinking,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
||||
// raw is the untouched body, so an adapter can read fields the gateway
|
||||
// has no reason to know (cache_control, service_tier, …).
|
||||
raw json.RawMessage
|
||||
}
|
||||
|
||||
// anthropicMessage accepts Anthropic's content as either a plain string or an
|
||||
// array of typed blocks; both normalize to the unified message shape.
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
// toUnified converts the parsed request into the internal format.
|
||||
func (a *anthropicRequest) toUnified(defaultModel string) (*types.ChatRequest, error) {
|
||||
if len(a.Messages) == 0 {
|
||||
return nil, errors.New("messages is required")
|
||||
}
|
||||
msgs := make([]types.ChatMessage, 0, len(a.Messages))
|
||||
for i, m := range a.Messages {
|
||||
u := types.ChatMessage{Role: m.Role, Content: normalizeAnthropicContent(m.Content)}
|
||||
if u.Role == "" {
|
||||
return nil, fmt.Errorf("messages[%d].role is required", i)
|
||||
}
|
||||
msgs = append(msgs, u)
|
||||
}
|
||||
model := a.Model
|
||||
if model == "" {
|
||||
model = defaultModel
|
||||
}
|
||||
th := a.Thinking
|
||||
if th == nil {
|
||||
// Anthropic's default is thinking OFF, and `thinking: {enabled:false}`
|
||||
// is the explicit spelling of that. Emitting it keeps the unified
|
||||
// request honest about the client's intent instead of leaving the
|
||||
// field absent and letting each adapter guess.
|
||||
no := false
|
||||
th = &types.ThinkingConfig{Enabled: &no}
|
||||
}
|
||||
return &types.ChatRequest{
|
||||
Model: model,
|
||||
Messages: msgs,
|
||||
Temperature: a.Temperature,
|
||||
MaxTokens: a.MaxTokens,
|
||||
Stream: a.Stream,
|
||||
TopP: a.TopP,
|
||||
TopK: a.TopK,
|
||||
StopSequences: a.StopSeqs,
|
||||
Tools: a.Tools,
|
||||
ToolChoice: a.ToolChoice,
|
||||
System: systemToString(a.System),
|
||||
Thinking: th,
|
||||
ExtraBody: map[string]interface{}{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeAnthropicContent turns Anthropic content into the unified shape.
|
||||
//
|
||||
// A string stays a string. An array of blocks is preserved as-is: the unified
|
||||
// format deliberately carries multimodal/tool blocks verbatim (ChatMessage
|
||||
// .Content is json.RawMessage) rather than flattening them, because only an
|
||||
// adapter knows which block types its upstream accepts.
|
||||
func normalizeAnthropicContent(raw json.RawMessage) json.RawMessage {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" {
|
||||
return types.StringContent("")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, `"`) {
|
||||
return raw
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// systemToString accepts Anthropic's two spellings: a plain string, or an array
|
||||
// of {type:"text", text} blocks.
|
||||
func systemToString(raw json.RawMessage) string {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(trimmed, `"`) {
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
var blocks []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &blocks); err != nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, b := range blocks {
|
||||
if b.Type == "text" {
|
||||
sb.WriteString(b.Text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseViaIngress is the kernel's INBOUND yield point, the dual of
|
||||
// serializerFor (which decides the outbound serializer).
|
||||
//
|
||||
// Design (user requirement): an inbound endpoint such as /v1/messages is the
|
||||
// KERNEL'S FALLBACK — the built-in parser in handleMessages is what an
|
||||
// unconfigured deployment gets. But an adapter may CLAIM that endpoint
|
||||
// (adapter.ingress == path) and declare deserialize_request; when it does, the
|
||||
// kernel YIELDS parsing to it, so client-protocol iteration lives in the
|
||||
// adapter and the kernel's own copy is only the safety net (R5).
|
||||
//
|
||||
// Returns handled=false when no adapter claims the path (or one claims it
|
||||
// without a deserialize_request hook) — the caller then runs its built-in
|
||||
// parser. A hook that exists but FAILS is a hard error, not a silent fallback:
|
||||
// the client spoke a protocol the adapter claimed to handle, and quietly
|
||||
// parsing it as a different protocol would mis-schedule the request.
|
||||
func (g *Gateway) parseViaIngress(path, body, defaultModel string) (*types.ChatRequest, bool, error) {
|
||||
vm := g.core.VM()
|
||||
if vm == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
adapter := vm.AdapterForIngress(path)
|
||||
if adapter == "" || !vm.HasFn(adapter, "deserialize_request") {
|
||||
return nil, false, nil
|
||||
}
|
||||
out, err := vm.Transform(adapter, "deserialize_request", body)
|
||||
if err != nil {
|
||||
return nil, true, fmt.Errorf("adapter %s deserialize_request(%s): %w", adapter, path, err)
|
||||
}
|
||||
var req types.ChatRequest
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
return nil, true, fmt.Errorf("adapter %s deserialize_request returned an invalid unified request: %w", adapter, err)
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = defaultModel
|
||||
}
|
||||
return &req, true, nil
|
||||
}
|
||||
|
||||
func chatKind(stream bool) string {
|
||||
if stream {
|
||||
return "stream"
|
||||
}
|
||||
return "chat"
|
||||
}
|
||||
|
||||
// directProviderList hands the serving-source resolver its candidate list.
|
||||
func directProviderList(direct *directRoute) []*provider.Provider {
|
||||
if direct == nil {
|
||||
return nil
|
||||
}
|
||||
return direct.cands
|
||||
}
|
||||
|
||||
func tierForRoute(direct *directRoute) int {
|
||||
if direct != nil {
|
||||
return -1
|
||||
}
|
||||
return -2
|
||||
}
|
||||
|
||||
func modelLabel(req *types.ChatRequest, usedModel string) string {
|
||||
if usedModel != "" {
|
||||
return usedModel
|
||||
}
|
||||
return req.Model
|
||||
}
|
||||
@ -1,404 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// 本文件端到端验证 Phase 2 的核心主张:**对外协议由适配器决定,内核只调度**。
|
||||
//
|
||||
// 具体做法:起一个真实的 core + gateway,源挂 anthropic 适配器,上游是一个
|
||||
// 只懂 Anthropic Messages 的假服务器。然后:
|
||||
//
|
||||
// 1. Anthropic 客户端形状的请求 → 拿到 Anthropic 形状的响应
|
||||
// 2. 调度/配额/审计与 /v1/chat/completions 走同一条路径
|
||||
// 3. 上游若不支持该模型,内核如实回 503,不伪造格式
|
||||
|
||||
// anthropicUpstream 是一个只实现 Anthropic Messages 的假上游。
|
||||
func anthropicUpstream(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/messages") {
|
||||
// 网关把请求发到了非 Anthropic 路径 —— 显式暴露这个 bug
|
||||
t.Errorf("请求打到了非 Anthropic 路径: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var probe map[string]interface{}
|
||||
_ = json.Unmarshal(body, &probe)
|
||||
if _, ok := probe["messages"]; !ok {
|
||||
t.Errorf("上游收到没有 messages 的请求体: %s", body)
|
||||
}
|
||||
if _, ok := probe["system"]; !ok && probe["system"] == nil {
|
||||
// system 是可选的,仅在客户端给了的时候才应出现;不强制
|
||||
_ = ok
|
||||
}
|
||||
if _, ok := probe["max_tokens"]; !ok {
|
||||
t.Errorf("Anthropic 要求 max_tokens,上游请求体却缺这个字段: %s", body)
|
||||
}
|
||||
|
||||
// Streaming requests must get a real Anthropic SSE sequence; a JSON body
|
||||
// here would leave the adapter with zero frames and the gateway would
|
||||
// (correctly) reject the stream as empty.
|
||||
if stream, _ := probe["stream"].(bool); stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fl, _ := w.(http.Flusher)
|
||||
frames := []string{
|
||||
`event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_mock","type":"message","role":"assistant","model":"` + probeModel(probe) + `","content":[],"usage":{"input_tokens":3,"output_tokens":0}}}`,
|
||||
`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
|
||||
`event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}`,
|
||||
`event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`,
|
||||
`event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
|
||||
`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
|
||||
}
|
||||
for _, f := range frames {
|
||||
fmt.Fprint(w, f+"\n\n")
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{
|
||||
"id":"msg_mock","type":"message","role":"assistant","model":"`+probeModel(probe)+`",
|
||||
"content":[{"type":"thinking","thinking":"let me think"},{"type":"text","text":"pong"}],
|
||||
"stop_reason":"end_turn",
|
||||
"usage":{"input_tokens":3,"output_tokens":5}
|
||||
}`)
|
||||
}))
|
||||
}
|
||||
|
||||
func probeModel(m map[string]interface{}) string {
|
||||
if s, ok := m["model"].(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// messagesGateway 起一个真实网关,源挂 anthropic 适配器。
|
||||
func messagesGateway(t *testing.T) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
up := anthropicUpstream(t)
|
||||
t.Cleanup(up.Close)
|
||||
|
||||
dir := t.TempDir()
|
||||
// adapterDir 故意不预先创建:VM 首启会把内置适配器 seed 进去。
|
||||
// 预先创建会被当成"目录已存在即为权威",于是没有任何内置适配器被加载,
|
||||
// 表现为 "adapter anthropic not loaded"。
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "claude-test",
|
||||
GatewayKeys: []string{"sk-e2e-0001"},
|
||||
Sources: []config.Source{{
|
||||
Name: "anth", BaseURL: up.URL, Adapter: "anthropic",
|
||||
APIKey: "k", MaxConcurrent: 4,
|
||||
Models: []config.Model{{ID: "claude-test", Kind: "chat"}},
|
||||
}},
|
||||
}
|
||||
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
gw, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(gw.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, "sk-e2e-0001"
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, url, key, body string) (int, string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
// TestMessagesEndpointSpeaksAnthropic 是本文件的主断言:Anthropic 客户端
|
||||
// 打 /v1/messages,拿到 Anthropic 形状。
|
||||
func TestMessagesEndpointSpeaksAnthropic(t *testing.T) {
|
||||
ts, key := messagesGateway(t)
|
||||
code, body := postJSON(t, ts.URL+"/v1/messages", key,
|
||||
`{"model":"claude-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("status = %d: %s", code, body)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||
t.Fatalf("响应不是 JSON: %v (%s)", err, body)
|
||||
}
|
||||
if m["type"] != "message" {
|
||||
t.Errorf("type = %v, want message(内核不该退回 OpenAI 的 chat.completion)", m["type"])
|
||||
}
|
||||
if m["stop_reason"] != "end_turn" {
|
||||
t.Errorf("stop_reason = %v, want end_turn", m["stop_reason"])
|
||||
}
|
||||
blocks, _ := m["content"].([]interface{})
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("content blocks = %v, want [thinking, text]", m["content"])
|
||||
}
|
||||
if b0 := blocks[0].(map[string]interface{}); b0["type"] != "thinking" || b0["thinking"] != "let me think" {
|
||||
t.Errorf("首个块应为 thinking: %v", b0)
|
||||
}
|
||||
if b1 := blocks[1].(map[string]interface{}); b1["type"] != "text" || b1["text"] != "pong" {
|
||||
t.Errorf("第二个块应为 text/pong: %v", b1)
|
||||
}
|
||||
u := m["usage"].(map[string]interface{})
|
||||
if u["input_tokens"] != float64(3) || u["output_tokens"] != float64(5) {
|
||||
t.Errorf("usage 应为 Anthropic 的 input/output 命名: %v", u)
|
||||
}
|
||||
// 内核不得混入 OpenAI 形状
|
||||
for _, forbidden := range []string{`"object"`, `"choices"`, `"chat.completion"`, `"prompt_tokens"`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Errorf("OpenAI 字段 %s 混入 Anthropic 响应: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessagesStreamSpeaksAnthropic 验证流式也是 Anthropic 事件序列,
|
||||
// 而不是 OpenAI SSE。
|
||||
func TestMessagesStreamSpeaksAnthropic(t *testing.T) {
|
||||
ts, key := messagesGateway(t)
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/messages",
|
||||
strings.NewReader(`{"model":"claude-test","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
s := string(body)
|
||||
|
||||
if !strings.Contains(s, "message_start") {
|
||||
t.Errorf("Anthropic 流应以 message_start 开头: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "message_stop") {
|
||||
t.Errorf("Anthropic 流应以 message_stop 收尾: %s", s)
|
||||
}
|
||||
// 内核不得擅自给 Anthropic 流加 OpenAI 的 [DONE]
|
||||
if strings.Contains(s, "[DONE]") {
|
||||
t.Errorf("Anthropic 流里出现了 OpenAI 的 [DONE] 终止标记: %s", s)
|
||||
}
|
||||
if strings.Contains(s, "chat.completion.chunk") {
|
||||
t.Errorf("流里混入 OpenAI chunk 形状: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessagesRejectsBadRequest 确认入向校验(Anthropic 要求 max_tokens)
|
||||
// 与 OpenAI 路径一样在入口拒绝,不占用上游槽位。
|
||||
func TestMessagesRejectsBadRequest(t *testing.T) {
|
||||
ts, key := messagesGateway(t)
|
||||
// Anthropic 允许省略 max_tokens(上游会给默认),所以「有 messages 无
|
||||
// max_tokens」必须被接受;真正该拒的是没有 messages。
|
||||
code, body := postJSON(t, ts.URL+"/v1/messages", key,
|
||||
`{"model":"claude-test","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("有 messages 的请求应被接受(max_tokens 可省),却得 %d: %s", code, body)
|
||||
}
|
||||
code, body = postJSON(t, ts.URL+"/v1/messages", key, `{"model":"claude-test"}`)
|
||||
if code != 400 {
|
||||
t.Errorf("无 messages 的请求 status = %d, want 400: %s", code, body)
|
||||
}
|
||||
// 坏 JSON
|
||||
code, _ = postJSON(t, ts.URL+"/v1/messages", key, `{"model":`)
|
||||
if code != 400 {
|
||||
t.Errorf("坏 JSON status = %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessagesUnconfiguredFallsBackToOpenai 验证 R5 的兜底在新增端点上同样
|
||||
// 生效:源没有声明序列化钩子时(此例用 openai 适配器),回 OpenAI 形状。
|
||||
func TestMessagesUnconfiguredFallsBackToOpenai(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
// adapterDir 故意不预先创建:VM 首启会把内置适配器 seed 进去。
|
||||
// 预先创建会被当成"目录已存在即为权威",于是没有任何内置适配器被加载,
|
||||
// 表现为 "adapter anthropic not loaded"。
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "gpt-test",
|
||||
GatewayKeys: []string{"sk-e2e-0001"},
|
||||
Sources: []config.Source{{
|
||||
Name: "oai", BaseURL: up.URL, Adapter: "openai", APIKey: "k", MaxConcurrent: 4,
|
||||
Models: []config.Model{{ID: "gpt-test", Kind: "chat"}},
|
||||
}},
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
gw, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(gw.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
code, body := postJSON(t, ts.URL+"/v1/messages", "sk-e2e-0001",
|
||||
`{"model":"gpt-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("status = %d: %s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "chat.completion") {
|
||||
t.Errorf("无序列化钩子的源应回落 OpenAI 兜底形状: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessagesRequiresAuth 确认新端点走同一套鉴权(不因为是新协议而放行)。
|
||||
func TestMessagesRequiresAuth(t *testing.T) {
|
||||
ts, _ := messagesGateway(t)
|
||||
code, _ := postJSON(t, ts.URL+"/v1/messages", "wrong-key",
|
||||
`{"model":"claude-test","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if code != 401 {
|
||||
t.Errorf("错误 key status = %d, want 401", code)
|
||||
}
|
||||
// 无 auth
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/messages", strings.NewReader("{}"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 401 {
|
||||
t.Errorf("无 key status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
_ = context.Background()
|
||||
}
|
||||
|
||||
// TestMessagesIngressYieldsToBundledAnthropic 是 ingress 让出的主断言(用户要求:
|
||||
// /v1/messages 是内核兜底端点,适配器可声明它、内核主动让出)。
|
||||
//
|
||||
// 事实前提:内置 anthropic.lua 自己声明了 adapter.ingress = "/v1/messages",而它
|
||||
// 默认随包播种。所以真实部署里内核**总是**让出给 anthropic 适配器,
|
||||
// anthropicRequest.toUnified 退化为纯兜底(仅在适配器被移除/禁用时运行)。
|
||||
//
|
||||
// 断言方式:给源挂一个「上游即 Anthropic」的假服务器,客户端送一个 Anthropic
|
||||
// 形状请求。若内核让出,请求由适配器 deserialize_request 解析 → 路由/调度正常。
|
||||
// 同时用一个**只有系统消息**的请求验证解析确实走了适配器语义(Go 侧 system
|
||||
// 处理与 Lua 侧一致)。
|
||||
func TestMessagesIngressYieldsToBundledAnthropic(t *testing.T) {
|
||||
ts, key := messagesGateway(t)
|
||||
if code, body := postJSON(t, ts.URL+"/v1/messages", key,
|
||||
`{"model":"claude-test","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}`); code != 200 {
|
||||
t.Fatalf("让出后 /v1/messages 应 200, 实得 %d: %s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterForIngressClaim 直接验证内核的让出判定:声明了 ingress 的适配器
|
||||
// 会被 AdapterForIngress 找到;未声明的路径返回空(→ 内核用自己的兜底解析)。
|
||||
func TestAdapterForIngressClaim(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "m",
|
||||
GatewayKeys: []string{"sk-e2e-0001"},
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
vm := c.VM()
|
||||
|
||||
// 内置 anthropic 声明了该端点
|
||||
if got := vm.AdapterForIngress("/v1/messages"); got != "anthropic" {
|
||||
t.Errorf("内置 anthropic 应认领 /v1/messages, 实得 %q", got)
|
||||
}
|
||||
// 未声明的路径 → 空 → 内核走自己的兜底解析
|
||||
if got := vm.AdapterForIngress("/v1/nothing"); got != "" {
|
||||
t.Errorf("未声明路径应为空, 实得 %q", got)
|
||||
}
|
||||
// 内置 anthropic 确实有 deserialize_request 钩子(否则内核会让出给一个空解析器)
|
||||
if !vm.HasFn("anthropic", "deserialize_request") {
|
||||
t.Error("anthropic 应实现 deserialize_request")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessagesKernelFallbackWhenNoAdapterClaims 证明兜底仍在:显式删掉所有
|
||||
// 声明 ingress 的适配器后,内核自己的 anthropicRequest.toUnified 接管,
|
||||
// /v1/messages 仍能服务(R5 兜底语义)。
|
||||
func TestMessagesKernelFallbackWhenNoAdapterClaims(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "gpt-test",
|
||||
GatewayKeys: []string{"sk-e2e-0001"},
|
||||
Sources: []config.Source{{
|
||||
Name: "oai", BaseURL: up.URL, Adapter: "openai", APIKey: "k", MaxConcurrent: 4,
|
||||
Models: []config.Model{{ID: "gpt-test", Kind: "chat"}},
|
||||
}},
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
// 摘掉声明 ingress 的适配器(内置 anthropic),模拟"没有适配器认领该端点"。
|
||||
c.VM().RemoveAdapter("anthropic")
|
||||
if got := c.VM().AdapterForIngress("/v1/messages"); got != "" {
|
||||
t.Fatalf("移除后应无适配器认领, 实得 %q", got)
|
||||
}
|
||||
gw, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(gw.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
// 内核兜底解析 Anthropic 形状 → 200(走 OpenAI 兜底序列化)
|
||||
code, body := postJSON(t, ts.URL+"/v1/messages", "sk-e2e-0001",
|
||||
`{"model":"gpt-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("无适配器认领时内核兜底应仍 200, 实得 %d: %s", code, body)
|
||||
}
|
||||
}
|
||||
@ -1,231 +0,0 @@
|
||||
// Package gateway — 对外挂载点(R3 / Phase 6)
|
||||
//
|
||||
// 用户需求:插件与适配器可以注册**对外服务的端口**。
|
||||
//
|
||||
// 现状:插件已经能注册 HTTP 端点,但被固定在 /api/plugins/http/ 前缀下。
|
||||
// 那条前缀限制是有理由的(可 grep、可整体撤销、插件无法影子内核路由),
|
||||
// 但它也意味着插件无法提供"看起来像内核一部分"的接口——而对外服务恰恰
|
||||
// 需要这个(例如给上游回调用的固定路径 /v1/plugins/<name>/callback)。
|
||||
//
|
||||
// 本文件放开这一点,同时把当初那条限制的**三个理由**逐条补回来:
|
||||
//
|
||||
// 1. 可 grep / 可整体撤销 → 每条自定义挂载都必须落在 /plugins/ 或
|
||||
// /v1/plugins/ 命名空间内(不接受任意前缀)
|
||||
// 2. 不能影子内核路由 → 与内核保留路由表做前缀冲突检测,撞了直接拒绝
|
||||
// 3. 鉴权默认关闭 → 沿用 HTTPHandler 的 roles/keys/public 语义
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// allowedMountPrefixes are the only namespaces a plugin/adapters may claim.
|
||||
//
|
||||
// A free-form prefix would let a plugin register "/v1/chat/completions" and
|
||||
// silently intercept every inference request on the gateway — the exact
|
||||
// shadowing the narrow default prefix was protecting against. Constraining the
|
||||
// namespace keeps the security property while still allowing a stable public
|
||||
// path.
|
||||
var allowedMountPrefixes = []string{"/plugins/", "/v1/plugins/"}
|
||||
|
||||
// kernelReservedPrefixes are prefixes the kernel owns. A custom mount that
|
||||
// overlaps any of them is refused, so an operator's route table can never
|
||||
// change meaning because a plugin was installed.
|
||||
//
|
||||
// This list is deliberately explicit rather than derived from the router's
|
||||
// switch: the switch is code, and a future branch added there would silently
|
||||
// become shadowable if this were inferred. The test
|
||||
// TestKernelRoutesAreAllCoveredByReservedPrefixes keeps the two in sync.
|
||||
var kernelReservedPrefixes = []string{
|
||||
"/v1/chat/completions",
|
||||
"/v1/messages",
|
||||
"/v1/images/generations",
|
||||
"/v1/models",
|
||||
"/api/",
|
||||
"/login",
|
||||
"/ui",
|
||||
"/",
|
||||
}
|
||||
|
||||
// ValidateCustomMount checks that a plugin-declared mount point is allowed.
|
||||
//
|
||||
// Returns the normalized path (leading slash, no trailing slash) or an error
|
||||
// naming the conflict. The error text is what an operator sees in the plugin's
|
||||
// load-error field, so it says WHICH rule was hit.
|
||||
func ValidateCustomMount(p string) (string, error) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("empty mount path")
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
return "", fmt.Errorf("mount path %q must start with /", p)
|
||||
}
|
||||
if strings.Contains(p, "..") || strings.Contains(p, "//") {
|
||||
return "", fmt.Errorf("mount path %q contains an empty or escaping segment", p)
|
||||
}
|
||||
norm := strings.TrimRight(p, "/")
|
||||
if norm == "" {
|
||||
norm = "/"
|
||||
}
|
||||
|
||||
// Rule 1: must live in one of the allowed namespaces.
|
||||
ok := false
|
||||
for _, pre := range allowedMountPrefixes {
|
||||
if strings.HasPrefix(norm+"/", pre) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("mount path %q must be under %s",
|
||||
norm, strings.Join(allowedMountPrefixes, " or "))
|
||||
}
|
||||
|
||||
// Rule 2: must not overlap a kernel route.
|
||||
for _, res := range kernelReservedPrefixes {
|
||||
if res == "/" {
|
||||
continue // the catch-all is handled by the namespace rule above
|
||||
}
|
||||
if norm == strings.TrimRight(res, "/") || strings.HasPrefix(norm, res) {
|
||||
return "", fmt.Errorf("mount path %q collides with the kernel route %q", norm, res)
|
||||
}
|
||||
}
|
||||
return norm, nil
|
||||
}
|
||||
|
||||
// RouteOwner describes one occupied route, for conflict reporting.
|
||||
type RouteOwner struct {
|
||||
Path string
|
||||
Owner string // "kernel", "plugin:name", "adapter:name"
|
||||
}
|
||||
|
||||
// BuildRouteTable assembles the set of paths a plugin/adapters must not take.
|
||||
// It is used both to validate new mounts and to explain a conflict to an
|
||||
// operator (a bare "conflict" is not actionable; "already served by plugin X" is).
|
||||
func BuildRouteTable(pluginMounts, adapterMounts map[string]string) []RouteOwner {
|
||||
var out []RouteOwner
|
||||
for _, p := range kernelReservedPrefixes {
|
||||
if p == "/" {
|
||||
continue
|
||||
}
|
||||
out = append(out, RouteOwner{Path: strings.TrimRight(p, "/"), Owner: "kernel"})
|
||||
}
|
||||
for path, owner := range pluginMounts {
|
||||
out = append(out, RouteOwner{Path: path, Owner: "plugin:" + owner})
|
||||
}
|
||||
for path, owner := range adapterMounts {
|
||||
out = append(out, RouteOwner{Path: path, Owner: "adapter:" + owner})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out
|
||||
}
|
||||
|
||||
// FindMountConflict reports whether candidate overlaps an occupied route and, if
|
||||
// so, who holds it.
|
||||
//
|
||||
// Overlap is checked in BOTH directions: a new mount that CONTAINS an existing
|
||||
// one shadows it just as effectively as one nested inside it, and only checking
|
||||
// the prefix direction would let "/plugins/x/sub" be registered before
|
||||
// "/plugins/x" and then silently swallow it.
|
||||
func FindMountConflict(candidate string, table []RouteOwner) (RouteOwner, bool) {
|
||||
c := strings.TrimRight(candidate, "/")
|
||||
for _, r := range table {
|
||||
rp := strings.TrimRight(r.Path, "/")
|
||||
if rp == "" {
|
||||
continue
|
||||
}
|
||||
if c == rp || strings.HasPrefix(c, rp+"/") || strings.HasPrefix(rp, c+"/") {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return RouteOwner{}, false
|
||||
}
|
||||
|
||||
// handleCustomMount dispatches a request to a plugin that claimed a custom mount
|
||||
// path (/plugins/<name>/… or /v1/plugins/<name>/…).
|
||||
//
|
||||
// It deliberately REUSES handlePluginHTTP rather than reimplementing dispatch:
|
||||
// that function already does longest-match resolution, per-endpoint
|
||||
// roles/keys/public authorization and the Lua call protocol. Duplicating it here
|
||||
// would create two dispatch paths that must be kept in agreement — and a
|
||||
// security rule that is enforced on only one of them is worse than none.
|
||||
//
|
||||
// The rewrite below maps a claimed mount onto the handler table's canonical
|
||||
// form: handler paths are declared as "<name>/<rest>", so the request path is
|
||||
// reduced to exactly that. Anything that does not reduce cleanly (a missing
|
||||
// name, a name with no registered handler) is a 404, not a fallthrough — a
|
||||
// custom namespace that silently answered from the default prefix would make
|
||||
// the mount validation meaningless.
|
||||
func (g *Gateway) handleCustomMount(w http.ResponseWriter, r *http.Request) {
|
||||
owner, rest, ok := parseMountRef(r.URL.Path)
|
||||
if !ok || owner == "" {
|
||||
writeError(w, http.StatusNotFound, "not_found", "malformed plugin mount path")
|
||||
return
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
|
||||
return
|
||||
}
|
||||
// Resolve which handler owns this mount, so an unclaimed namespace is a 404.
|
||||
handlers := ps.HTTPHandlers()
|
||||
candidate := owner
|
||||
if rest != "" {
|
||||
candidate = owner + "/" + rest
|
||||
}
|
||||
if !mountClaimed(handlers, candidate) {
|
||||
writeError(w, http.StatusNotFound, "not_found",
|
||||
"no plugin serves "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
// Reuse the default dispatcher by presenting the path in its own shape.
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = pluginHTTPPrefix + candidate
|
||||
g.handlePluginHTTP(w, r2)
|
||||
}
|
||||
|
||||
// mountClaimed reports whether any registered handler's path is a prefix of the
|
||||
// candidate (longest-match semantics: /plugins/auth/token is served by a
|
||||
// handler declared as "auth").
|
||||
func mountClaimed(handlers []lua.HTTPHandler, candidate string) bool {
|
||||
for i := range handlers {
|
||||
h := strings.Trim(handlers[i].Path, "/")
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
if candidate == h || strings.HasPrefix(candidate, h+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseMountRef splits "/plugins/<name>/<rest>" or "/v1/plugins/<name>/<rest>"
|
||||
// into its owner name and remaining path.
|
||||
//
|
||||
// The "/v1/plugins/" form is checked first because it has the longer prefix:
|
||||
// testing "/plugins/" first would match nothing for a /v1/ path, but keeping the
|
||||
// ordering explicit avoids a future prefix being added that nests inside
|
||||
// another.
|
||||
func parseMountRef(p string) (owner, rest string, ok bool) {
|
||||
prefixes := []string{"/v1/plugins/", "/plugins/"}
|
||||
for _, prefix := range prefixes {
|
||||
if !strings.HasPrefix(p, prefix) {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimPrefix(p, prefix)
|
||||
if trimmed == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if i := strings.Index(trimmed, "/"); i >= 0 {
|
||||
return trimmed[:i], trimmed[i+1:], true
|
||||
}
|
||||
return trimmed, "", true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
@ -1,324 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// 本文件锁定 R3 的两条不变量:
|
||||
// 1. 自定义挂载只能落在允许的命名空间内
|
||||
// 2. 任何内核路由都不能被插件声明覆盖 —— 且这条在新增内核路由时**不会失效**
|
||||
|
||||
// TestValidateCustomMountNamespace 只有 /plugins/ 与 /v1/plugins/ 可被占用。
|
||||
func TestValidateCustomMountNamespace(t *testing.T) {
|
||||
ok := []string{
|
||||
"/plugins/auth/token",
|
||||
"/plugins/auth",
|
||||
"/v1/plugins/callback",
|
||||
"/v1/plugins/callback/recv",
|
||||
"/plugins/nested/deep/path",
|
||||
}
|
||||
for _, p := range ok {
|
||||
if _, err := ValidateCustomMount(p); err != nil {
|
||||
t.Errorf("合法挂载 %q 被拒: %v", p, err)
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
"",
|
||||
"plugins/x", // 没有前导斜杠
|
||||
"/v1/chat/completions", // 影子内核路由(且不在允许命名空间)
|
||||
"/api/anything", // 内核命名空间
|
||||
"/callback", // 任意前缀
|
||||
"/plugins/../etc", // 逃逸
|
||||
"/plugins//double", // 空段
|
||||
"/v1/plugins/../../x",
|
||||
}
|
||||
for _, p := range bad {
|
||||
if _, err := ValidateCustomMount(p); err == nil {
|
||||
t.Errorf("非法挂载 %q 被接受", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomMountCannotShadowKernelRoute 逐条核对内核保留前缀。
|
||||
func TestCustomMountCannotShadowKernelRoute(t *testing.T) {
|
||||
// 这些是要保护的公开接口;任何能覆盖它们的挂载都必须被拒。
|
||||
for _, kernel := range []string{"/v1/chat/completions", "/v1/messages", "/v1/models"} {
|
||||
// 尝试用允许命名空间里的路径去"贴近"内核路由 —— 例如
|
||||
// /v1/plugins/../../v1/models(逃逸),必须被拒。
|
||||
cand := "/v1/plugins/../../" + strings.TrimPrefix(kernel, "/")
|
||||
if _, err := ValidateCustomMount(cand); err == nil {
|
||||
t.Errorf("逃逸路径 %q 应被拒", cand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelRoutesAreAllCoveredByReservedPrefixes 是这道防线**不腐坏**的保证。
|
||||
//
|
||||
// 它从 server.go 里把路由 switch 的分支字符串刮出来,逐个检查是否被
|
||||
// kernelReservedPrefixes 覆盖。将来有人新增一条内核路由却忘了更新保留表时,
|
||||
// 这条测试会失败 —— 否则新路由会静默变成可被插件覆盖的。
|
||||
func TestKernelRoutesAreAllCoveredByReservedPrefixes(t *testing.T) {
|
||||
src, err := os.ReadFile("server.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 抓 switch 里的字面量路径:case r.URL.Path == "/x" / HasPrefix(..., "/x")
|
||||
re := regexp.MustCompile(`r\.URL\.Path\s*==\s*"(/[^"]*)"`)
|
||||
matches := re.FindAllStringSubmatch(string(src), -1)
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("没有从 server.go 抓到任何路由字面量 —— 测试自身失效")
|
||||
}
|
||||
covered := func(p string) bool {
|
||||
// "/" is the UI catch-all (serveUI). It is the default branch, not a
|
||||
// path a plugin could shadow, and it is covered by the namespace rule:
|
||||
// a custom mount must start with /plugins/ or /v1/plugins/, neither of
|
||||
// which is "/".
|
||||
if p == "/" {
|
||||
return true
|
||||
}
|
||||
for _, res := range kernelReservedPrefixes {
|
||||
if res == "/" {
|
||||
continue
|
||||
}
|
||||
rp := strings.TrimRight(res, "/")
|
||||
if p == rp || strings.HasPrefix(p, rp+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// /api/plugins/... 由 pluginHTTPPrefix 与 /api/ 覆盖
|
||||
if strings.HasPrefix(p, "/api/") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, m := range matches {
|
||||
p := m[1]
|
||||
if !covered(p) {
|
||||
t.Errorf("内核路由 %q 未被 kernelReservedPrefixes 覆盖 —— "+
|
||||
"新增路由时必须同步更新保留表,否则插件可影子它", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMountConflictDetectionBothDirections 冲突检测必须双向。
|
||||
//
|
||||
// 只查一个方向会让「先注册 /plugins/x,后注册 /plugins/x/sub」或反过来时的
|
||||
// 一个被另一个静默吞掉。
|
||||
func TestMountConflictDetectionBothDirections(t *testing.T) {
|
||||
table := []RouteOwner{{Path: "/plugins/x", Owner: "plugin:a"}}
|
||||
// 被包含
|
||||
if _, conflict := FindMountConflict("/plugins/x/sub", table); !conflict {
|
||||
t.Error("嵌套在已有挂载下的路径应判冲突")
|
||||
}
|
||||
// 包含
|
||||
if _, conflict := FindMountConflict("/plugins", table); !conflict {
|
||||
t.Error("包含已有挂载的路径应判冲突")
|
||||
}
|
||||
// 相同
|
||||
if _, conflict := FindMountConflict("/plugins/x", table); !conflict {
|
||||
t.Error("完全相同的路径应判冲突")
|
||||
}
|
||||
// 无关(前缀相似但不是路径段)
|
||||
if _, conflict := FindMountConflict("/plugins/xy", table); conflict {
|
||||
t.Error("/plugins/xy 与 /plugins/x 不是同一个路径段,不该判冲突")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMountConflictNamesTheOwner 冲突信息必须点名持有者(否则不可行动)。
|
||||
func TestMountConflictNamesTheOwner(t *testing.T) {
|
||||
table := []RouteOwner{{Path: "/plugins/taken", Owner: "plugin:other"}}
|
||||
owner, conflict := FindMountConflict("/plugins/taken/deep", table)
|
||||
if !conflict {
|
||||
t.Fatal("应判冲突")
|
||||
}
|
||||
if owner.Owner != "plugin:other" {
|
||||
t.Errorf("应点名持有者 plugin:other, 实得 %q", owner.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseMountRef 解析两种命名空间。
|
||||
func TestParseMountRef(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, owner, rest string
|
||||
ok bool
|
||||
}{
|
||||
{"/plugins/auth/token", "auth", "token", true},
|
||||
{"/plugins/auth", "auth", "", true},
|
||||
{"/v1/plugins/cb/recv", "cb", "recv", true},
|
||||
{"/v1/plugins/cb", "cb", "", true},
|
||||
{"/plugins/", "", "", false},
|
||||
{"/plugins", "", "", false},
|
||||
{"/other/x", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
owner, rest, ok := parseMountRef(c.in)
|
||||
if ok != c.ok || owner != c.owner || rest != c.rest {
|
||||
t.Errorf("parseMountRef(%q) = (%q,%q,%v), want (%q,%q,%v)",
|
||||
c.in, owner, rest, ok, c.owner, c.rest, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRouteTableIncludesKernel 路由表含内核保留项(供冲突提示用)。
|
||||
func TestBuildRouteTableIncludesKernel(t *testing.T) {
|
||||
table := BuildRouteTable(
|
||||
map[string]string{"/plugins/a": "alpha"},
|
||||
map[string]string{"/v1/plugins/b": "beta"},
|
||||
)
|
||||
var hasKernel, hasPlugin, hasAdapter bool
|
||||
for _, r := range table {
|
||||
if r.Owner == "kernel" {
|
||||
hasKernel = true
|
||||
}
|
||||
if r.Owner == "plugin:alpha" {
|
||||
hasPlugin = true
|
||||
}
|
||||
if r.Owner == "adapter:beta" {
|
||||
hasAdapter = true
|
||||
}
|
||||
}
|
||||
if !hasKernel || !hasPlugin || !hasAdapter {
|
||||
t.Errorf("路由表应含 kernel/plugin/adapter 三类: %+v", table)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginServesCustomMountEndToEnd 是 R3 的端到端验收:
|
||||
// 插件声明一个自定义对外挂载点,客户端按该路径访问,得到插件的应答。
|
||||
func TestPluginServesCustomMountEndToEnd(t *testing.T) {
|
||||
ts, key := messagesGateway(t)
|
||||
// messagesGateway 用的 core 没有插件目录配置,这里直接建一个带插件的。
|
||||
_ = ts
|
||||
_ = key
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
PluginDir: filepath.Join(dir, "plugins"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-mount-0001"},
|
||||
Sources: []config.Source{{
|
||||
Name: "s", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
APIKey: "k", MaxConcurrent: 1,
|
||||
Models: []config.Model{{ID: "m", Kind: "chat"}},
|
||||
}},
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
gw, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts2 := httptest.NewServer(gw.Handler())
|
||||
defer ts2.Close()
|
||||
|
||||
// 装一个声明自定义挂载的插件
|
||||
code := `
|
||||
local plugin = { name = "publicapi", version = "1.0" }
|
||||
plugin.http = {
|
||||
{ path = "publicapi/token", method = "GET", handler = "token",
|
||||
roles = { "admin", "user" }, description = "对外 token 端点" },
|
||||
}
|
||||
function plugin.token(req)
|
||||
return { status = 200, body = '{"token":"abc"}' }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("publicapi", code); err != nil {
|
||||
t.Fatalf("load plugin: %v", err)
|
||||
}
|
||||
|
||||
// 按自定义挂载访问
|
||||
req, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("自定义挂载 status = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "abc") {
|
||||
t.Errorf("自定义挂载未返回插件应答: %s", body)
|
||||
}
|
||||
|
||||
// /v1/plugins/ 形式同样可用
|
||||
req2, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != 200 {
|
||||
t.Errorf("/v1/plugins/ 形式 status = %d", resp2.StatusCode)
|
||||
}
|
||||
|
||||
// 未声明的命名空间 404(不能因为落在允许命名空间就放行)
|
||||
req3, _ := http.NewRequest("GET", ts2.URL+"/plugins/nobody/here", nil)
|
||||
req3.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
|
||||
resp3, err := http.DefaultClient.Do(req3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp3.Body.Close()
|
||||
if resp3.StatusCode != 404 {
|
||||
t.Errorf("未注册的挂载应 404, 实得 %d", resp3.StatusCode)
|
||||
}
|
||||
|
||||
// 鉴权仍生效:无 key 时,
|
||||
// - API 路径(/v1/plugins/…)应 401(isAPIPath 判定)
|
||||
// - 浏览器路径(/plugins/…)应 302 跳登录页
|
||||
req4, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil)
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp4, err := noRedirect.Do(req4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp4.Body.Close()
|
||||
if resp4.StatusCode != 401 {
|
||||
t.Errorf("无 key 访问 /v1/plugins/ 应 401, 实得 %d", resp4.StatusCode)
|
||||
}
|
||||
|
||||
req5, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil)
|
||||
resp5, err := noRedirect.Do(req5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp5.Body.Close()
|
||||
if resp5.StatusCode != 302 {
|
||||
t.Errorf("无 key 访问 /plugins/ 应 302 跳登录, 实得 %d", resp5.StatusCode)
|
||||
}
|
||||
if loc := resp5.Header.Get("Location"); !strings.Contains(loc, "/login") {
|
||||
t.Errorf("应跳转到 /login, 实得 %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomMountCannotInterceptKernelRoute 端到端确认内核路由不受影响:
|
||||
// 即使有插件在用 /plugins/,/v1/chat/completions 与 /v1/models 仍归内核。
|
||||
func TestCustomMountCannotInterceptKernelRoute(t *testing.T) {
|
||||
for _, p := range []string{"/v1/chat/completions", "/v1/models", "/v1/messages", "/api/stats"} {
|
||||
if _, err := ValidateCustomMount(p); err == nil {
|
||||
t.Errorf("内核路由 %q 必须不能被声明为自定义挂载", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 变异测试会**改写生产源码** `discover.go`(discover_mutate_test.go),
|
||||
// 靠 defer 把原文件写回。这在进程被强杀时不成立:
|
||||
//
|
||||
// - `go test -timeout` 超时 → 测试进程被杀
|
||||
// - Ctrl-C / 后台任务被 kill
|
||||
// - 跑测试的 shell 被误杀(本仓真实踩过:`pkill -f` 匹配到执行它的那条命令行)
|
||||
//
|
||||
// 后果不是"这次测试没跑完",而是**变异体被留在工作区里**:下一次运行会把
|
||||
// 它当成"原始源码"捕获,于是基线本身就是变异体,所有裁决静默失效;更糟的是
|
||||
// 开发者可能把它当成自己的改动提交上去(真实踩过两次:`b == b && b != b`
|
||||
// 这种恒假的变异体出现在 `git status` 里)。
|
||||
//
|
||||
// 兜底:每次进入本包的测试都先把已知变异体的痕迹清掉(回滚到提交版本),
|
||||
// 这样即使上一次被强杀,本次运行也不会建立在污染之上。TestRealMutants 自己
|
||||
// 也会在脏树上拒绝运行,但那是"拒绝",这里是"自愈"。
|
||||
func TestMain(m *testing.M) {
|
||||
restoreLeakedMutants()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// leakedMutants 列出「变异体特征 → 它对应的正确原文」。
|
||||
// 只处理能**确定判定**的形态:出现特征即说明上一次没回滚干净。
|
||||
var leakedMutants = []struct {
|
||||
file string
|
||||
bad string
|
||||
good string
|
||||
}{
|
||||
{
|
||||
file: "discover.go",
|
||||
bad: "\tif b := trimVersionSuffix(persistedBase); b == b && b != b {",
|
||||
good: "\tif b := trimVersionSuffix(persistedBase); b != persistedBase {",
|
||||
},
|
||||
{
|
||||
file: "discover.go",
|
||||
bad: "\t\tif len(pair) > 0 && (seenPair[pair] || seenURL[base+path]) && false {",
|
||||
good: "\t\tif seenPair[pair] || seenURL[base+path] {",
|
||||
},
|
||||
}
|
||||
|
||||
func restoreLeakedMutants() {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, m := range leakedMutants {
|
||||
p := filepath.Join(wd, m.file)
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s := string(b)
|
||||
if !strings.Contains(s, m.bad) {
|
||||
continue
|
||||
}
|
||||
// 只替换回原文,不猜别的差异 —— 保守,避免把开发中的改动一起冲掉。
|
||||
fixed := strings.Replace(s, m.bad, m.good, 1)
|
||||
if fixed == s {
|
||||
continue
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(fixed), 0o644)
|
||||
}
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/scheduler"
|
||||
"modelrouter/internal/types"
|
||||
)
|
||||
|
||||
// piOverflowRe 是 @earendil-works/pi-ai 的 OVERFLOW_PATTERNS 中对应的一条。
|
||||
// 归一化标记必须命中它,否则改了等于没改:客户端仍不会压缩重试。
|
||||
var piOverflowRe = regexp.MustCompile(`context[_ ]length[_ ]exceeded`)
|
||||
|
||||
func TestOverflowCanonicalMatchesPiPattern(t *testing.T) {
|
||||
if !piOverflowRe.MatchString(overflowCanonical) {
|
||||
t.Fatalf("归一化标记 %q 不匹配 pi 的超窗正则,客户端仍不会压缩重试", overflowCanonical)
|
||||
}
|
||||
}
|
||||
|
||||
// justworker 的真实措辞:与 pi 现有 25 条正则一条都不匹配,
|
||||
// 所以必须在网关侧归一化,否则超窗对客户端完全不可见。
|
||||
func TestJustworkerOverflowIsNormalized(t *testing.T) {
|
||||
raw := "请精简对话历史…(Context window is full, please reduce the conversation history)…"
|
||||
|
||||
// 前提校验:原始措辞确实不被 pi 认出(否则本用例失去意义)。
|
||||
if piOverflowRe.MatchString(raw) {
|
||||
t.Fatalf("前提不成立:原始措辞本就匹配 pi 正则: %q", raw)
|
||||
}
|
||||
if !looksLikeOverflow(raw) {
|
||||
t.Fatalf("网关应识别出这是超窗: %q", raw)
|
||||
}
|
||||
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{
|
||||
Tier: 1, Source: "justworker", Model: "claude-opus-5", Err: errors.New(raw),
|
||||
}}}
|
||||
|
||||
got := clientUpstreamErr(err)
|
||||
if !piOverflowRe.MatchString(got) {
|
||||
t.Fatalf("归一化后仍不匹配 pi 正则,客户端不会压缩重试: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 直连路径(非 AUTO 链)也要归一化——它走的是同一个入口。
|
||||
func TestDirectPathOverflowIsNormalized(t *testing.T) {
|
||||
got := clientUpstreamErr(errors.New("upstream 400: Context window is full"))
|
||||
if !piOverflowRe.MatchString(got) {
|
||||
t.Fatalf("直连超窗未被归一化: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 非超窗错误不能被加上超窗标记,否则会把普通失败误报成需要压缩。
|
||||
func TestNonOverflowNotNormalized(t *testing.T) {
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{
|
||||
Tier: 1, Source: "x", Model: "y", Err: errors.New("502 Bad Gateway: upstream connect error"),
|
||||
}}}
|
||||
got := clientUpstreamErr(err)
|
||||
if strings.Contains(got, overflowCanonical) {
|
||||
t.Fatalf("普通上游失败被误标为超窗: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "502 Bad Gateway") {
|
||||
t.Fatalf("普通失败的诊断信息应保留: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:短诊断词常落在错误尾部,80 字节截断会正好把它切掉,
|
||||
// 超窗就永远认不出来。宽度放宽后必须保住。
|
||||
func TestOverflowPhraseSurvivesTruncationWidth(t *testing.T) {
|
||||
prefix := strings.Repeat("x", 100)
|
||||
// OneLine 的长度限制作用于整条 "source/model: err",不是只算 err 本身。
|
||||
full := "justworker/claude-opus-5: " + prefix + " Context window is full"
|
||||
|
||||
// 前提:80 字节下短语确实会被切掉(这正是修复前的行为)。
|
||||
if got := types.OneLine(full, 80); strings.Contains(got, "Context window is full") {
|
||||
t.Fatalf("前提不成立:80 字节下短语居然还在: %q", got)
|
||||
}
|
||||
// 160 字节下必须保住,否则超窗识别会被截断悄悄破坏。
|
||||
if got := types.OneLine(full, 160); !strings.Contains(got, "Context window is full") {
|
||||
t.Fatalf("160 字节应保住尾部短诊断词: %q", got)
|
||||
}
|
||||
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{
|
||||
Tier: 1, Source: "justworker", Model: "claude-opus-5",
|
||||
Err: errors.New(prefix + " Context window is full"),
|
||||
}}}
|
||||
|
||||
got := clientUpstreamErr(err)
|
||||
if !strings.Contains(strings.ToLower(got), "context window is full") {
|
||||
t.Fatalf("宽度 160 应保住尾部短诊断词,实际: %q", got)
|
||||
}
|
||||
if !piOverflowRe.MatchString(got) {
|
||||
t.Fatalf("归一化标记丢失: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// piNonOverflowRe 是 @earendil-works/pi-ai 的 NON_OVERFLOW_PATTERNS。
|
||||
// pi 的 isContextOverflow 先查这一组,命中则直接判为「非超窗」——
|
||||
// 即使 OVERFLOW_PATTERNS 也命中,也不会压缩重试。
|
||||
var piNonOverflowRe = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(Throttling error|Service unavailable):`),
|
||||
regexp.MustCompile(`(?i)rate limit`),
|
||||
regexp.MustCompile(`(?i)too many requests`),
|
||||
}
|
||||
|
||||
// piWouldCompact 复刻 isContextOverflow 的 Case 1 判定顺序:
|
||||
// 先排除 NON_OVERFLOW,再匹配 OVERFLOW。
|
||||
func piWouldCompact(errorMessage string) bool {
|
||||
for _, p := range piNonOverflowRe {
|
||||
if p.MatchString(errorMessage) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return piOverflowRe.MatchString(errorMessage)
|
||||
}
|
||||
|
||||
// issue 回归:AUTO 链失败消息天然把「超窗 tier」和「配额/限流 tier」拼在一起,
|
||||
// 超窗标记后面只要出现 rate limit / too many requests 的字样,
|
||||
// pi 就会把整条消息判为「非超窗」而拒绝压缩重试。
|
||||
func TestOverflowMessageSurvivesRateLimitedSiblingTier(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sibling string
|
||||
}{
|
||||
{"rate limit 措辞", "api error 429: rate limit exceeded"},
|
||||
{"too many requests 措辞", "429 Too Many Requests"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{
|
||||
{Tier: 1, Source: "sensenova", Model: "glm-5.2", Err: errors.New(tc.sibling)},
|
||||
{Tier: 3, Source: "gozen", Model: "deepseek-v4.1-flash",
|
||||
Err: errors.New("api error 400: This model's maximum context length is 1048576 tokens")},
|
||||
}}
|
||||
|
||||
got := clientUpstreamErr(err)
|
||||
if !piWouldCompact(got) {
|
||||
t.Fatalf("同链限流 tier 封杀了超窗识别,pi 不会压缩重试: %q", got)
|
||||
}
|
||||
// 干净:不得把限流 tier 的措辞带出去。
|
||||
for _, p := range piNonOverflowRe {
|
||||
if p.MatchString(got) {
|
||||
t.Fatalf("超窗消息仍带限流措辞 %v: %q", p, got)
|
||||
}
|
||||
}
|
||||
// 但仍要能看出是哪个源超的窗。
|
||||
if !strings.Contains(got, "gozen/deepseek-v4.1-flash") {
|
||||
t.Errorf("超窗源信息丢失: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 直连超窗同样要干净可用。
|
||||
func TestDirectOverflowMessageIsClean(t *testing.T) {
|
||||
raw := "api error 400: Error from provider (Console Go): This model's maximum context length is 1048576 tokens. " +
|
||||
"However, you requested 1440050 tokens. Please reduce the length of the messages or completion."
|
||||
got := clientUpstreamErr(errors.New(raw))
|
||||
if !piWouldCompact(got) {
|
||||
t.Fatalf("直连超窗未被 pi 识别: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, overflowCanonical) {
|
||||
t.Fatalf("缺少归一化标记: %q", got)
|
||||
}
|
||||
}
|
||||
@ -1,150 +0,0 @@
|
||||
package gateway
|
||||
|
||||
// The /api/plugins/{name}/call/{fn} endpoint splits its path with string
|
||||
// surgery, and the split point is easy to get wrong in a way that still
|
||||
// "works": keeping the "call/" segment in the function name produced
|
||||
// `does not export "call/collect_account"` — the plugin was correct, the router
|
||||
// was not, and every call 502'd while the exports list looked perfectly fine.
|
||||
//
|
||||
// These tests drive the real router, so a refactor that reintroduces the
|
||||
// off-by-one is caught here rather than in production.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// callPlugin exposes exactly one export plus a function that is NOT exported.
|
||||
// If the exports gate is not honoured, secretFn is reachable and with it the
|
||||
// plugin's private state — for a plugin holding an API key that means handing
|
||||
// the key to anyone who can POST to the gateway.
|
||||
const callPlugin = `
|
||||
local P = {}
|
||||
P.name = "calltest"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "add" }
|
||||
P.secret = "do-not-leak"
|
||||
function P.add(a)
|
||||
return { result = (tonumber(a and a.x) or 0) + 1 }
|
||||
end
|
||||
function P.secretFn()
|
||||
return { leaked = P.secret }
|
||||
end
|
||||
return P
|
||||
`
|
||||
|
||||
func gatewayWithCallPlugin(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
body := "listen: :0\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"gateway_keys:\n - sk-test\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
if err := c.Plugins().LoadSource("calltest", callPlugin); err != nil {
|
||||
t.Fatalf("load calltest: %v", err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestPluginCallSplitsFunctionNameCorrectly(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodPost,
|
||||
"/api/plugins/calltest/call/add", `{"x":41}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("call failed: HTTP %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Result struct {
|
||||
Result int `json:"result"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
|
||||
}
|
||||
// 41 + 1 == 42. Reaching 42 proves BOTH halves of the contract: the "/call/"
|
||||
// segment did not leak into the function name, and the payload arrived.
|
||||
if out.Result.Result != 42 {
|
||||
t.Fatalf("got %d, want 42 — either the /call/ segment leaked into the "+
|
||||
"function name or the argument table never reached Lua", out.Result.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCallRefusesUndeclaredFunction(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodPost,
|
||||
"/api/plugins/calltest/call/secretFn", `{}`)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Fatalf("an unexported function must not be callable, got 200: %s",
|
||||
rr.Body.String())
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), "do-not-leak") {
|
||||
t.Fatal("the refusal leaked the plugin's private state")
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that declares no exports at all has no callable surface. Reading the
|
||||
// missing list as "allow everything" would turn every plugin into a remote
|
||||
// evaluator the moment this endpoint ships.
|
||||
func TestPluginCallWithoutExportsHasNoCallableSurface(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
const bare = `
|
||||
local P = {}
|
||||
P.name = "bare"
|
||||
P.version = "1.0.0"
|
||||
function P.anything()
|
||||
return { ok = true }
|
||||
end
|
||||
return P
|
||||
`
|
||||
if err := g.core.Plugins().LoadSource("bare", bare); err != nil {
|
||||
t.Fatalf("load bare: %v", err)
|
||||
}
|
||||
rr := doReq(t, g, http.MethodPost,
|
||||
"/api/plugins/bare/call/anything", `{}`)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Fatalf("a plugin with no plugin.exports must expose nothing, got 200: %s",
|
||||
rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCallIsAdminOnly(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
// A real user-role key, not a bare one from config: sk-test/sk-user in the
|
||||
// config both default to admin, so a test using them would pass the gate for
|
||||
// the wrong reason and prove nothing.
|
||||
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := doReqAs(t, g, rec.Key, http.MethodPost,
|
||||
"/api/plugins/calltest/call/add", `{"x":1}`)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s",
|
||||
rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
@ -1,285 +0,0 @@
|
||||
package gateway
|
||||
|
||||
// Mount for plugin-registered HTTP endpoints.
|
||||
//
|
||||
// The gateway owns the listener, the authentication, the role check and the
|
||||
// response framing; the plugin owns the logic. That split is the point: a plugin
|
||||
// cannot open a socket, cannot bypass auth, and cannot choose a path outside
|
||||
// /api/plugins/http/.
|
||||
//
|
||||
// Why this exists rather than "plugins can call each other": some things are
|
||||
// worth querying from OUTSIDE the browser — a CI job asking whether the upstream
|
||||
// balance is above zero, a monitoring probe, another tool on the box. billing
|
||||
// never wanted this (it only receives hooks); a quota plugin does. Hard-coding a
|
||||
// route per plugin would mean a gateway change for every such plugin, and an
|
||||
// arbitrary plugin's code in the main mux is not something the routing table
|
||||
// should ever grow.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's
|
||||
// routes live under one prefix, which makes them greppable, makes them easy to
|
||||
// revoke wholesale, and means a plugin cannot shadow /api/keys or /api/stats.
|
||||
const pluginHTTPPrefix = "/api/plugins/http/"
|
||||
|
||||
// handlePluginHTTP dispatches a request to whichever plugin registered the
|
||||
// longest matching path.
|
||||
//
|
||||
// Longest-match rather than first-match: a plugin may register /health and
|
||||
// another /health/deep, and both must work. With registration order deciding it,
|
||||
// the outcome would depend on load order and change when a plugin is added.
|
||||
func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, pluginHTTPPrefix)
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
|
||||
return
|
||||
}
|
||||
|
||||
matches := ps.HTTPHandlers()
|
||||
var chosen *lua.HTTPHandler
|
||||
var params map[string]string
|
||||
// contenders holds the handlers that matched at the current best depth with
|
||||
// the SAME literal route pattern — i.e. two plugins that both declared this
|
||||
// exact path. That is the contested case the operator must arbitrate (user
|
||||
// requirement A): rather than let load order pick a silent winner, a
|
||||
// contested path with no recorded decision returns 409 naming the candidates.
|
||||
var contenders []lua.HTTPHandler
|
||||
for i := range matches {
|
||||
h := matches[i]
|
||||
p, ok := matchHandlerPath(h.Path, rest)
|
||||
if h.Prefix {
|
||||
pattern := strings.Trim(h.Path, "/")
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
n := len(strings.Split(pattern, "/"))
|
||||
ok = len(parts) >= n
|
||||
if ok {
|
||||
p, ok = matchHandlerPath(pattern, strings.Join(parts[:n], "/"))
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path):
|
||||
chosen, params = &matches[i], p
|
||||
contenders = []lua.HTTPHandler{matches[i]}
|
||||
case segmentCount(h.Path) == segmentCount(chosen.Path) && h.Path == chosen.Path:
|
||||
// Same route pattern, a different plugin. The winner is arbitrated below.
|
||||
contenders = append(contenders, matches[i])
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found",
|
||||
"no plugin serves this path")
|
||||
return
|
||||
}
|
||||
if len(contenders) > 1 {
|
||||
resolved := g.resolveContestedRoute(w, ps, chosen.Path, contenders)
|
||||
if resolved == nil {
|
||||
return // 409 already written, naming the candidates
|
||||
}
|
||||
chosen = resolved
|
||||
}
|
||||
|
||||
if !strings.EqualFold(r.Method, chosen.Method) {
|
||||
w.Header().Set("Allow", chosen.Method)
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
||||
"this endpoint accepts "+chosen.Method+" only")
|
||||
return
|
||||
}
|
||||
|
||||
role := reqRole(r.Context())
|
||||
if !chosen.Allows(role, reqKeyName(r.Context())) {
|
||||
writeError(w, http.StatusForbidden, "forbidden",
|
||||
handlerAccessHint(*chosen))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
req := &lua.HandlerRequest{
|
||||
Method: r.Method,
|
||||
Path: rest,
|
||||
Query: flattenQuery(r.URL.Query()),
|
||||
Params: params,
|
||||
Headers: flattenHeader(r.Header),
|
||||
Body: body,
|
||||
Role: role,
|
||||
Key: reqKey(r.Context()),
|
||||
}
|
||||
resp, err := ps.InvokeHandler(handlerOwner(chosen), chosen.Handler, req)
|
||||
if err != nil {
|
||||
// A plugin error is a 502, not a 500: the gateway is fine, the plugin is
|
||||
// not, and the operator needs to tell those apart in their logs.
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
if resp.Proxy != "" {
|
||||
if !chosen.Prefix {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", "proxy response requires prefix=true")
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
n := len(strings.Split(strings.Trim(chosen.Path, "/"), "/"))
|
||||
g.servePluginProxy(w, r, resp.Proxy, pluginHTTPPrefix+strings.Join(parts[:n], "/"))
|
||||
return
|
||||
}
|
||||
lua.WriteHandlerResponse(w, resp)
|
||||
}
|
||||
|
||||
// resolveContestedRoute arbitrates a service path that more than one plugin
|
||||
// declared (user requirement A). It returns the winning handler, or nil after
|
||||
// writing a 409 when the operator has not yet chosen.
|
||||
//
|
||||
// This is the crux of the "report, don't crash, don't silently pick" rule: with
|
||||
// two plugins both serving e.g. "stats", load order used to decide the winner.
|
||||
// Now an unresolved contest is a 409 that NAMES the candidates, and the operator
|
||||
// picks one in the WebUI (POST /api/plugins/conflicts). A recorded decision is
|
||||
// honoured here on every request thereafter.
|
||||
func (g *Gateway) resolveContestedRoute(w http.ResponseWriter, ps *lua.Plugins, path string, contenders []lua.HTTPHandler) *lua.HTTPHandler {
|
||||
winner := ps.RouteWinner(path)
|
||||
if winner != "" {
|
||||
for i := range contenders {
|
||||
if contenders[i].Plugin == winner {
|
||||
return &contenders[i]
|
||||
}
|
||||
}
|
||||
// A decision exists but points at a plugin that no longer serves this
|
||||
// path (it was removed or edited). Fall through to 409 rather than honour
|
||||
// a stale winner that cannot answer.
|
||||
}
|
||||
names := make([]string, 0, len(contenders))
|
||||
for i := range contenders {
|
||||
names = append(names, contenders[i].Plugin)
|
||||
}
|
||||
sort.Strings(names)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writeJSON(w, http.StatusConflict, map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"type": "route_conflict",
|
||||
"message": "service path \"" + path + "\" is claimed by " + strconv.Itoa(len(names)) + " plugins (" + strings.Join(names, ", ") + "); an operator must choose one in the admin UI (Conflicts) or POST /api/plugins/conflicts",
|
||||
"path": path,
|
||||
"candidates": names,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlerAccessHint explains, in the refusal, exactly which declaration would
|
||||
// open this endpoint. A 403 with no reason is the kind of thing an operator
|
||||
// opens the source to work out, and then the fix is a guess.
|
||||
func handlerAccessHint(h lua.HTTPHandler) string {
|
||||
switch {
|
||||
case len(h.Keys) > 0:
|
||||
return "this endpoint allows only the named keys (" +
|
||||
strings.Join(h.Keys, ", ") + "); authenticate with one of them"
|
||||
case len(h.Roles) > 0:
|
||||
return "this endpoint requires role " + strings.Join(h.Roles, " or ") +
|
||||
" (widen it in the plugin's http declaration, or set public=true)"
|
||||
default:
|
||||
return "this endpoint declares no roles, so it is admin-only; " +
|
||||
"set roles=… or keys=… in the plugin's http declaration"
|
||||
}
|
||||
}
|
||||
|
||||
// handlerOwner returns the plugin name that registered h. The gateway stores the
|
||||
// owner alongside the handler precisely so this does not have to be guessed from
|
||||
// the path prefix.
|
||||
func handlerOwner(h *lua.HTTPHandler) string { return h.Plugin }
|
||||
|
||||
// segmentCount is how deep a route is, used for longest-match. A path with more
|
||||
// segments wins, so /health/deep beats /health when both are registered.
|
||||
func segmentCount(p string) int { return strings.Count(p, "/") }
|
||||
|
||||
// matchHandlerPath matches a route pattern against a concrete path, capturing
|
||||
// ":name" segments.
|
||||
//
|
||||
// Returns ok=false on a segment-count mismatch or a missing value for a
|
||||
// param — a route must not match with an empty capture, or two different URLs
|
||||
// would reach the same handler indistinguishably.
|
||||
func matchHandlerPath(pattern, path string) (map[string]string, bool) {
|
||||
pp := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||
sp := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(pp) != len(sp) {
|
||||
return nil, false
|
||||
}
|
||||
var params map[string]string
|
||||
for i := range pp {
|
||||
if strings.HasPrefix(pp[i], ":") {
|
||||
name := pp[i][1:]
|
||||
if sp[i] == "" {
|
||||
return nil, false
|
||||
}
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
params[name] = sp[i]
|
||||
continue
|
||||
}
|
||||
if pp[i] != sp[i] {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return params, true
|
||||
}
|
||||
|
||||
func flattenQuery(q map[string][]string) map[string]string {
|
||||
out := make(map[string]string, len(q))
|
||||
for k, v := range q {
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenHeader(h http.Header) map[string]string {
|
||||
out := make(map[string]string, len(h))
|
||||
for k := range h {
|
||||
out[strings.ToLower(k)] = h.Get(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readBody(r *http.Request) (string, error) {
|
||||
if r.Body == nil {
|
||||
return "", nil
|
||||
}
|
||||
defer r.Body.Close()
|
||||
// A cap, because the handler runs inside the gateway process: an unbounded
|
||||
// body from an authenticated caller is a memory exhaustion vector.
|
||||
const maxBody = 4 << 20
|
||||
buf := make([]byte, 0, 4096)
|
||||
tmp := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := r.Body.Read(tmp)
|
||||
if n > 0 {
|
||||
if len(buf)+n > maxBody {
|
||||
return "", &bodyTooLarge{}
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
type bodyTooLarge struct{}
|
||||
|
||||
func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" }
|
||||
|
||||
// limitBody is kept for the numeric form some callers want in a log line.
|
||||
var _ = strconv.Itoa
|
||||
@ -1,54 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// servePluginProxy forwards a plugin-owned prefix to an explicit loopback
|
||||
// origin. No destination or path may be supplied by the client. Authentication
|
||||
// and endpoint authorization have already completed before this is called.
|
||||
func (g *Gateway) servePluginProxy(w http.ResponseWriter, r *http.Request, target, prefix string) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy must name a loopback HTTP origin")
|
||||
return
|
||||
}
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
if ip == nil || !ip.IsLoopback() || u.Port() == "" {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy requires a literal loopback address and port")
|
||||
return
|
||||
}
|
||||
// WebSocket requests are browser-initiated and carry ambient cookies. Do not
|
||||
// allow a foreign website to use an administrator's session to open a tunnel.
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
o, e := url.Parse(origin)
|
||||
if e != nil || !strings.EqualFold(o.Host, r.Host) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "cross-origin proxy request refused")
|
||||
return
|
||||
}
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(u)
|
||||
director := proxy.Director
|
||||
proxy.Director = func(out *http.Request) {
|
||||
director(out)
|
||||
out.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if out.URL.Path == "" {
|
||||
out.URL.Path = "/"
|
||||
}
|
||||
out.URL.RawPath = ""
|
||||
out.Host = u.Host
|
||||
out.Header.Del("Authorization")
|
||||
out.Header.Del("Cookie")
|
||||
q := out.URL.Query()
|
||||
q.Del("api_key")
|
||||
out.URL.RawQuery = q.Encode()
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "plugin service unavailable")
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginProxyRejectsUnsafeTargetsAndOrigin(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
for _, target := range []string{"http://example.com:80", "http://localhost:80", "https://127.0.0.1:80", "http://127.0.0.1", "http://u:p@127.0.0.1:80", "http://127.0.0.1:80/path", "http://127.0.0.1:80/?x=1"} {
|
||||
rr := httptest.NewRecorder()
|
||||
g.servePluginProxy(rr, httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/live.js", nil), target, "/api/plugins/http/relay/s")
|
||||
if rr.Code != 502 {
|
||||
t.Errorf("unsafe target %q accepted: %d", target, rr.Code)
|
||||
}
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/websockify", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
g.servePluginProxy(rr, req, "http://127.0.0.1:12345", "/api/plugins/http/relay/s")
|
||||
if rr.Code != 403 {
|
||||
t.Fatalf("foreign origin accepted: %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPrefixProxyAuthorizationAndRevocation(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("denied request reached backend") }))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := `local p={name="relaydeny",http={{path="relaydeny/:session",prefix=true,roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req) return {status=404,body="expired session"} end
|
||||
return p`
|
||||
if err := g.core.Plugins().LoadSource("relaydeny", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
for _, tc := range []struct {
|
||||
path, key string
|
||||
status int
|
||||
}{
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "", 401},
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "sk-test", 404},
|
||||
{"/api/plugins/http/relaydeny-not-a-child/expired/vnc.html", "sk-test", 404},
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", server.URL+tc.path, nil)
|
||||
if tc.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tc.key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != tc.status {
|
||||
t.Fatalf("%s got %d %s", tc.path, resp.StatusCode, b)
|
||||
}
|
||||
if tc.key != "" && strings.Contains(tc.path, "/expired/") && strings.Contains(tc.path, "/relaydeny/") && string(b) != "expired session" {
|
||||
t.Fatalf("revocation response=%s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Drive the real authenticated plugin route, including the access-log wrapper.
|
||||
// A successful static request alone does not prove WebSocket can upgrade.
|
||||
func TestPluginPrefixProxyHTTPAndUpgrade(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.URL.Query().Get("api_key") != "" {
|
||||
t.Error("gateway credentials leaked to plugin backend")
|
||||
}
|
||||
if r.URL.Path == "/websockify" {
|
||||
c, b, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprint(b, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
b.Flush()
|
||||
io.Copy(c, c)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, r.URL.Path+"?"+r.URL.RawQuery)
|
||||
}))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := fmt.Sprintf(`local p={name="relay",http={{path="relay/:session",prefix=true,method="GET",roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req)
|
||||
if req.params.session ~= "live" then return {status=404,body="expired"} end
|
||||
return {proxy=%q}
|
||||
end
|
||||
return p`, backend.URL)
|
||||
if err := g.core.Plugins().LoadSource("relay", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/plugins/http/relay/live/app/main.js?x=1&api_key=sk-test", nil)
|
||||
req.Header.Set("Authorization", "Bearer sk-test")
|
||||
req.AddCookie(&http.Cookie{Name: "gw_key", Value: "sk-test"})
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || string(body) != "/app/main.js?x=1" {
|
||||
t.Fatalf("HTTP proxy: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
c, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprintf(c, "GET /api/plugins/http/relay/live/websockify HTTP/1.1\r\nHost: %s\r\nAuthorization: Bearer sk-test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", strings.TrimPrefix(server.URL, "http://"))
|
||||
b := bufio.NewReader(c)
|
||||
reply, err := http.ReadResponse(b, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply.StatusCode != 101 {
|
||||
t.Fatalf("upgrade failed: %s", reply.Status)
|
||||
}
|
||||
c.Write([]byte("ping"))
|
||||
got := make([]byte, 4)
|
||||
if _, err = io.ReadFull(b, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "ping" {
|
||||
t.Fatalf("tunnel payload=%q", got)
|
||||
}
|
||||
}
|
||||
@ -1,626 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
)
|
||||
|
||||
// newFailingServer serves handler and returns its test server.
|
||||
func newFailingServer(h http.Handler) *httptest.Server { return httptest.NewServer(h) }
|
||||
|
||||
// THE TEST THAT WAS MISSING.
|
||||
//
|
||||
// Every other plugin test calls Plugins.Fire() directly. That proves the Lua
|
||||
// runtime works, but it says nothing about whether the GATEWAY actually fires
|
||||
// the stages — the wiring in handleChat / singleChat / writeRec could be
|
||||
// deleted and the whole suite would stay green while no hook ever ran in
|
||||
// production.
|
||||
//
|
||||
// So these tests drive real HTTP requests through the real handler and assert
|
||||
// on what the plugin observed.
|
||||
|
||||
// observed is what a spy plugin recorded. It is written from Lua, so the
|
||||
// counters live in plugin state and are read back through the state API.
|
||||
type spyPayload struct {
|
||||
Stage string
|
||||
Type string
|
||||
Model string
|
||||
Source string
|
||||
OK bool
|
||||
Tokens int64
|
||||
}
|
||||
|
||||
// spyPlugin returns Lua source for a plugin that appends every stage it sees to
|
||||
// `state.seen`. It is generated per test so each test gets a clean log.
|
||||
func spyPlugin() string {
|
||||
return `
|
||||
local plugin = { name = "spy", version = "1.0.0" }
|
||||
plugin.state = { seen = {} }
|
||||
plugin.hooks = {
|
||||
request_start = "on_start",
|
||||
routed = "on_routed",
|
||||
request_end = "on_end",
|
||||
}
|
||||
local function rec(p, stage)
|
||||
table.insert(plugin.state.seen, {
|
||||
stage = stage, type = p.type or "", model = p.model or "",
|
||||
source = p.source or "", ok = p.ok and true or false,
|
||||
tokens = p.prompt_tokens or 0,
|
||||
})
|
||||
end
|
||||
function plugin.on_start(p) rec(p, "request_start") return nil end
|
||||
function plugin.on_routed(p) rec(p, "routed") return nil end
|
||||
function plugin.on_end(p) rec(p, "request_end") return nil end
|
||||
return plugin
|
||||
`
|
||||
}
|
||||
|
||||
// gatewayWithSpy boots a gateway with one mock upstream and the spy plugin
|
||||
// loaded, and returns the gateway plus the upstream URL.
|
||||
func gatewayWithSpy(t *testing.T) (*Gateway, string) {
|
||||
t.Helper()
|
||||
up := mockUpstream()
|
||||
t.Cleanup(up.Close)
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
cfgBody := "listen: :0\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"gateway_keys:\n - sk-test\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "spyup",
|
||||
BaseURL: up.URL,
|
||||
Adapter: "openai",
|
||||
APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "spy-model", Kind: "chat"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil {
|
||||
t.Fatalf("load spy: %v", err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g, up.URL
|
||||
}
|
||||
|
||||
// seenStages reads the spy's log out of its published state.
|
||||
func seenStages(t *testing.T, g *Gateway) []map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := g.core.Plugins().State("spy")
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var st struct {
|
||||
Seen []map[string]interface{} `json:"seen"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.Seen
|
||||
}
|
||||
|
||||
func stageNames(seen []map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, s := range seen {
|
||||
out = append(out, s["stage"].(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestHooksFireOnRealDirectChat: a plain non-streaming chat through the real
|
||||
// handler must produce request_start, routed and request_end — in that order,
|
||||
// with the real source and model filled in.
|
||||
func TestHooksFireOnRealDirectChat(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"spy-model","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("chat status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
seen := seenStages(t, g)
|
||||
got := stageNames(seen)
|
||||
want := []string{"request_start", "routed", "request_end"}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("stages = %v, want %v (a hook that never fires is the failure "+
|
||||
"this test exists for)", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("stage order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
// The payload must be real data, not empty scaffolding.
|
||||
end := seen[2]
|
||||
if end["source"] != "spyup" {
|
||||
t.Errorf("request_end source = %v, want spyup", end["source"])
|
||||
}
|
||||
if end["model"] != "spy-model" {
|
||||
t.Errorf("request_end model = %v, want spy-model", end["model"])
|
||||
}
|
||||
if end["ok"] != true {
|
||||
t.Errorf("request_end ok = %v, want true", end["ok"])
|
||||
}
|
||||
if toInt(t, end["tokens"]) <= 0 {
|
||||
t.Errorf("request_end prompt_tokens = %v, want the mock upstream's real usage", end["tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksFireOnRealStreamChat: streaming is a DIFFERENT code path (the record
|
||||
// is written from a deferred call after the stream ends), so it needs its own
|
||||
// assertion. A hook that only worked on the non-streaming path would be a real
|
||||
// bug for agent clients, which are the main streaming users.
|
||||
func TestHooksFireOnRealStreamChat(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"spy-model","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("stream status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got := stageNames(seenStages(t, g))
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("stream stages = %v, want all three", got)
|
||||
}
|
||||
if got[0] != "request_start" || got[2] != "request_end" {
|
||||
t.Errorf("stream stage order = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksFireOnAutoRequest: the AUTO chain path is a third code path. It also
|
||||
// must fire, and routed must report tier -2 (resolved by the chain) rather than
|
||||
// the direct path's -1.
|
||||
func TestHooksFireOnAutoRequest(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
// Register the model in the AUTO chain via the admin API.
|
||||
put := doReq(t, g, http.MethodPut, "/api/auto", `{"rules":[{"model":"spy-model"}]}`)
|
||||
if put.Code != http.StatusOK {
|
||||
t.Fatalf("save auto: %d %s", put.Code, put.Body.String())
|
||||
}
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("auto chat status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
seen := seenStages(t, g)
|
||||
if len(seen) != 3 {
|
||||
t.Fatalf("AUTO stages = %v, want three", stageNames(seen))
|
||||
}
|
||||
if seen[0]["model"] != "AUTO" {
|
||||
t.Errorf("request_start model = %v, want the requested \"AUTO\"", seen[0]["model"])
|
||||
}
|
||||
if seen[1]["model"] != "spy-model" {
|
||||
t.Errorf("routed model = %v, want the RESOLVED model", seen[1]["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksFireOnFailedRequest is the one that matters most for accounting: a
|
||||
// request that 500s upstream must still reach request_end, or a billing plugin
|
||||
// would never see failed traffic.
|
||||
func TestHooksFireOnFailedRequest(t *testing.T) {
|
||||
// Point at an upstream that always fails.
|
||||
bad := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
})
|
||||
up := newFailingServer(bad)
|
||||
defer up.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
cfgBody := "listen: :0\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"gateway_keys:\n - sk-test\n"
|
||||
os.WriteFile(cfgPath, []byte(cfgBody), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "badup", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "bad-model", Kind: "chat"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"bad-model","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Fatal("expected the upstream failure to surface as a non-200")
|
||||
}
|
||||
seen := seenStages(t, g)
|
||||
// request_start must fire; routed must NOT (no source was ever selected);
|
||||
// request_end MUST (otherwise billing misses failed traffic).
|
||||
got := stageNames(seen)
|
||||
if len(got) == 0 || got[0] != "request_start" {
|
||||
t.Fatalf("stages = %v, want request_start first", got)
|
||||
}
|
||||
hasEnd := false
|
||||
for _, s := range seen {
|
||||
if s["stage"] == "request_end" {
|
||||
hasEnd = true
|
||||
if s["ok"] != false {
|
||||
t.Error("request_end reported ok=true for a failed request")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasEnd {
|
||||
t.Error("a FAILED request never reached request_end; a billing plugin " +
|
||||
"would not see it at all")
|
||||
}
|
||||
if contains(got, "routed") {
|
||||
t.Error("routed fired for a request that never selected a source")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrokenPluginDoesNotBreakForwarding is the safety property, asserted
|
||||
// end-to-end: a plugin that throws in EVERY stage must not stop a chat from
|
||||
// being served.
|
||||
func TestBrokenPluginDoesNotBreakForwarding(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(cfgPath, []byte("listen: :0\n"+
|
||||
"adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+
|
||||
"plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+
|
||||
"runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+
|
||||
"gateway_keys:\n - sk-test\n"), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "u", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "m", Kind: "chat"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
broken := `
|
||||
local plugin = { name = "broken", version = "1.0.0" }
|
||||
plugin.hooks = { request_start = "f", routed = "f", request_end = "f" }
|
||||
function plugin.f(p) error("plugin is broken") end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("broken", broken); err != nil {
|
||||
t.Fatalf("load broken: %v", err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("a broken plugin broke forwarding: status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "pong") {
|
||||
t.Errorf("the response is not the upstream's: %s", rr.Body.String())
|
||||
}
|
||||
// And the failure must be visible rather than silent.
|
||||
if len(c.Plugins().HookErrors()) == 0 {
|
||||
t.Error("a plugin that threw in every stage left no error record")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginListIsServedWithStages is the operator-facing check: an admin
|
||||
// debugging a plugin needs to see which stages are live and whether the hook
|
||||
// file is where the kernel thinks it is.
|
||||
func TestPluginListIsServedWithStages(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rr.Code)
|
||||
}
|
||||
for _, want := range []string{"spy", "request_start", "routed", "request_end", "plugin_dir"} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Errorf("/api/plugins response lacks %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toInt(t *testing.T, v interface{}) int64 {
|
||||
t.Helper()
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int64(f)
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// imageUpstream serves an OpenAI-shaped image response.
|
||||
func imageUpstream() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"created":1,"data":[{"b64_json":"aGk="}]}`))
|
||||
}))
|
||||
}
|
||||
|
||||
// TestHooksFireOnRealImageRequest: the image path is a THIRD entry point. It
|
||||
// has its own handler and its own scheduler call, so "chat works" says nothing
|
||||
// about it — and image traffic is billable, so a cost plugin that never sees
|
||||
// it under-reports.
|
||||
func TestHooksFireOnRealImageRequest(t *testing.T) {
|
||||
up := imageUpstream()
|
||||
defer up.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(cfgPath, []byte("listen: :0\n"+
|
||||
"adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+
|
||||
"plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+
|
||||
"runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+
|
||||
"gateway_keys:\n - sk-test\n"), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "imgup", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "img-model", Kind: "image"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/images/generations",
|
||||
`{"model":"img-model","prompt":"a cat"}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("image status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
seen := seenStages(t, g)
|
||||
got := stageNames(seen)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("image stages = %v, want all three (image is a separate handler)", got)
|
||||
}
|
||||
if seen[0]["type"] != "image" {
|
||||
t.Errorf("request_start type = %v, want \"image\"", seen[0]["type"])
|
||||
}
|
||||
if seen[1]["source"] != "imgup" {
|
||||
t.Errorf("routed source = %v, want imgup", seen[1]["source"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRejectedChatStillFiresRequestStart pins the ordering decision: request_start
|
||||
// fires BEFORE the quota/scope gates, so a plugin auditing traffic sees rejected
|
||||
// requests too. A plugin that only ever saw served requests could not compute a
|
||||
// true request rate.
|
||||
func TestRejectedChatStillFiresRequestStart(t *testing.T) {
|
||||
up := mockUpstream()
|
||||
defer up.Close()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(cfgPath, []byte("listen: :0\n"+
|
||||
"adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+
|
||||
"plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+
|
||||
"runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+
|
||||
"gateway_keys:\n - sk-test\n"), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{{
|
||||
Name: "u", BaseURL: up.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "m", Kind: "chat"}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Plugins().LoadSource("spy", spyPlugin()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A model the gateway does not know => 404, rejected before any routing.
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"no-such-model","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for an unknown model, got %d", rr.Code)
|
||||
}
|
||||
got := stageNames(seenStages(t, g))
|
||||
if !contains(got, "request_start") {
|
||||
t.Errorf("stages = %v; a REJECTED request must still fire request_start so a "+
|
||||
"plugin can count real traffic, not just served traffic", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainStepReachesPluginOnDegradation is the end-to-end proof for the
|
||||
// AUTO trace: a request that had to drop from tier 1 to tier 2 must be visible
|
||||
// to a plugin as a tier_skip followed by a selected, and request_end must carry
|
||||
// tier_served=2.
|
||||
//
|
||||
// Before the trace existed the plugin saw only tier=-2 ("resolved by the
|
||||
// chain") and could not tell a degradation from a clean tier-1 hit — which is
|
||||
// the whole question a priority chain exists to answer.
|
||||
func TestChainStepReachesPluginOnDegradation(t *testing.T) {
|
||||
// tier 1's source always fails, so the walk must drop to tier 2.
|
||||
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
}))
|
||||
defer bad.Close()
|
||||
good := mockUpstream()
|
||||
defer good.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(cfgPath, []byte("listen: :0\n"+
|
||||
"adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+
|
||||
"plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+
|
||||
"runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+
|
||||
"gateway_keys:\n - sk-test\n"), 0600)
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Sources = []config.Source{
|
||||
{Name: "t1", BaseURL: bad.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "hi-tier", Kind: "chat"}}},
|
||||
{Name: "t2", BaseURL: good.URL, Adapter: "openai", APIKey: "sk-x",
|
||||
Models: []config.Model{{ID: "lo-tier", Kind: "chat"}}},
|
||||
}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// A spy that records chain_step events too.
|
||||
sp := `
|
||||
local plugin = { name = "walker", version = "1.0.0" }
|
||||
plugin.state = { steps = {}, ends = {} }
|
||||
plugin.hooks = { chain_step = "step", request_end = "fin" }
|
||||
function plugin.step(p)
|
||||
table.insert(plugin.state.steps, { kind = p.kind, tier = p.tier, source = p.source, model = p.model, reason = p.reason })
|
||||
return nil
|
||||
end
|
||||
function plugin.fin(p)
|
||||
plugin.state.ends[#plugin.state.ends + 1] = {
|
||||
tier_served = p.tier_served, degraded = p.degraded,
|
||||
walk = p.chain_walk, source = p.source, model = p.model,
|
||||
}
|
||||
return nil
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("walker", sp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two tiers, both in the chain.
|
||||
if rr := doReq(t, g, http.MethodPut, "/api/auto",
|
||||
`{"rules":[{"model":"hi-tier","source":"t1","tier":1},{"model":"lo-tier","source":"t2","tier":2}]}`); rr.Code != 200 {
|
||||
t.Fatalf("save auto: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
|
||||
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("chat = %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
raw := c.Plugins().State("walker")
|
||||
b, _ := json.Marshal(raw)
|
||||
var st struct {
|
||||
Steps []struct {
|
||||
Kind string `json:"kind"`
|
||||
Tier int `json:"tier"`
|
||||
Source string `json:"source"`
|
||||
Model string `json:"model"`
|
||||
} `json:"steps"`
|
||||
Ends []struct {
|
||||
TierServed int `json:"tier_served"`
|
||||
Degraded bool `json:"degraded"`
|
||||
Source string `json:"source"`
|
||||
Model string `json:"model"`
|
||||
} `json:"ends"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, string(b))
|
||||
}
|
||||
if len(st.Steps) < 2 {
|
||||
t.Fatalf("chain_step events = %+v, want at least a slot_fail and a selected", st.Steps)
|
||||
}
|
||||
if st.Steps[0].Kind != "slot_fail" || st.Steps[0].Tier != 1 {
|
||||
t.Errorf("first step = %+v, want slot_fail on tier 1", st.Steps[0])
|
||||
}
|
||||
last := st.Steps[len(st.Steps)-1]
|
||||
if last.Kind != "selected" || last.Tier != 2 {
|
||||
t.Errorf("last step = %+v, want selected on tier 2", last)
|
||||
}
|
||||
if len(st.Ends) != 1 {
|
||||
t.Fatalf("request_end count = %d, want 1", len(st.Ends))
|
||||
}
|
||||
if st.Ends[0].TierServed != 2 {
|
||||
t.Errorf("tier_served = %d, want 2", st.Ends[0].TierServed)
|
||||
}
|
||||
if !st.Ends[0].Degraded {
|
||||
t.Error("degraded = false, but the request dropped a tier")
|
||||
}
|
||||
if st.Ends[0].Model != "lo-tier" {
|
||||
t.Errorf("served model = %q, want lo-tier", st.Ends[0].Model)
|
||||
}
|
||||
}
|
||||
@ -1,751 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
var (
|
||||
errPluginName = errors.New("plugin name must be non-empty and contain no path separator or dot")
|
||||
errNoPluginDir = errors.New("no plugin_dir configured; set plugin_dir in config.yaml to enable plugins")
|
||||
)
|
||||
|
||||
// Plugin management API.
|
||||
//
|
||||
// GET /api/plugins list loaded plugins + their hooks/UI/errors
|
||||
// POST /api/plugins upload/replace one plugin (.lua), hot-applied
|
||||
// DELETE /api/plugins/{name} remove a plugin
|
||||
// GET /api/plugins/{name}/state the plugin's own published state
|
||||
// PUT /api/plugins/{name}/state replace that state (admin only)
|
||||
//
|
||||
// Why state is a first-class endpoint: request_end is the LAST stage, so a
|
||||
// hook's return value has no downstream consumer inside the gateway. A plugin
|
||||
// that accumulates numbers (the billing plugin does exactly this) therefore
|
||||
// keeps them in its own Lua state and serves them here, which is what its UI
|
||||
// component fetches. This keeps plugin data clearly separated from the
|
||||
// gateway's own stats — see the accounting note in docs/plugins.md: the
|
||||
// gateway's quota accounting stays authoritative, a plugin only reports.
|
||||
|
||||
// handlePluginUI serves the merged UI extensions the WebUI injects at boot.
|
||||
//
|
||||
// It is a single GET (not per-plugin) because the browser needs ALL extensions
|
||||
// before it can build the sidebar: a page contributed by one plugin and an
|
||||
// element contributed by another land in the same payload, and fetching them
|
||||
// separately would mean the sidebar has to be rebuilt as each arrives.
|
||||
//
|
||||
// Served to any authenticated role: the WebUI is authenticated before it asks,
|
||||
// and a plugin's own widgets need to render for the user whose key they show.
|
||||
func (g *Gateway) handlePluginUI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
||||
return
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ui": lua.UIExtension{}})
|
||||
return
|
||||
}
|
||||
// AllStages, not a hand-written list: an earlier version enumerated the
|
||||
// three stages literally here, so when chain_step was added it was silently
|
||||
// missing from this response — a plugin author reading the discovery
|
||||
// document would have believed the stage did not exist. Deriving it from
|
||||
// the one place that defines the order is the whole point of having it.
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ui": ps.UI(),
|
||||
"stages": pipelineStageNames(),
|
||||
})
|
||||
}
|
||||
|
||||
// stageNames returns the pipeline stage names in firing order, for the
|
||||
// discovery payload and for tests.
|
||||
func pipelineStageNames() []string {
|
||||
out := make([]string, 0, len(lua.AllStages))
|
||||
for _, s := range lua.AllStages {
|
||||
out = append(out, string(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"plugins": []interface{}{}})
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/plugins")
|
||||
path = strings.Trim(path, "/")
|
||||
|
||||
// /api/plugins/conflicts — the arbitration surface (user requirement).
|
||||
// GET lists every contested route (A) and every suspended adapter-name
|
||||
// install (B), each with its candidates/incumbent and any recorded
|
||||
// decision.
|
||||
// POST {kind,path|name,winner} records the operator's choice and persists
|
||||
// it. Must be routed BEFORE the single-segment {name} handler below,
|
||||
// which would otherwise read "conflicts" as a plugin name.
|
||||
if path == "conflicts" {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"routes": ps.RouteConflicts(),
|
||||
"adapters": ps.AdapterConflicts(),
|
||||
})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Kind string `json:"kind"` // "route" or "adapter"
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Winner string `json:"winner"` // route: plugin name; adapter: "incumbent"|"candidate"
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
switch body.Kind {
|
||||
case "route":
|
||||
if err := ps.ResolveRouteConflict(body.Path, body.Winner); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "conflict_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "path": body.Path, "winner": body.Winner})
|
||||
case "adapter":
|
||||
if err := ps.ResolveAdapterConflict(body.Name, body.Winner); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "conflict_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name, "winner": body.Winner})
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "kind must be \"route\" or \"adapter\"")
|
||||
}
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// /api/plugins/{name}/state
|
||||
if strings.HasSuffix(path, "/state") {
|
||||
name := strings.TrimSuffix(path, "/state")
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
|
||||
return
|
||||
}
|
||||
g.handlePluginState(w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
// /api/plugins/{name}/http/{path}/access — the operator's view of, and edit
|
||||
// surface for, which keys an endpoint answers.
|
||||
if i := strings.Index(path, "/http/"); i >= 0 {
|
||||
name := path[:i]
|
||||
rest := path[i+len("/http/"):]
|
||||
if strings.HasSuffix(rest, "/access") {
|
||||
epPath := strings.TrimSuffix(rest, "/access")
|
||||
g.handleEndpointAccess(w, r, name, epPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// /api/plugins/{name}/kernel — the operator's view of, and edit surface for,
|
||||
// which kernel actions a plugin may call, plus its declared extra outbound hosts.
|
||||
if strings.HasSuffix(path, "/kernel") {
|
||||
name := strings.TrimSuffix(path, "/kernel")
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
|
||||
return
|
||||
}
|
||||
g.handlePluginKernel(w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
// /api/plugins/{name}/login — REMOVED. Login sessions are plugin domain:
|
||||
// A plugin owns its own session state and serves it through its own
|
||||
// mount. The kernel has no login concept.
|
||||
|
||||
// /api/plugins/{name}/call/{fn}
|
||||
if i := strings.Index(path, "/call/"); i >= 0 {
|
||||
name := path[:i]
|
||||
fn := path[i+len("/call/"):]
|
||||
if name == "" || fn == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "call needs a plugin and a function name")
|
||||
return
|
||||
}
|
||||
g.handlePluginCall(w, r, name, fn)
|
||||
return
|
||||
}
|
||||
|
||||
// /api/plugins/{name}/fanout/{fn} — one call per argument set, in
|
||||
// parallel, from one request. See handlePluginFanout for why the
|
||||
// request-per-item form cannot be made concurrent from the client.
|
||||
if i := strings.Index(path, "/fanout/"); i >= 0 {
|
||||
name := path[:i]
|
||||
fn := path[i+len("/fanout/"):]
|
||||
if name == "" || fn == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "fanout needs a plugin and a function name")
|
||||
return
|
||||
}
|
||||
g.handlePluginFanout(w, r, name, fn)
|
||||
return
|
||||
}
|
||||
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"plugins": ps.List(),
|
||||
"on_disk": ps.OnDisk(),
|
||||
"hook_errors": ps.HookErrors(),
|
||||
"plugin_dir": g.pluginDir(),
|
||||
// Visibility overrides and the endpoint access table, so the
|
||||
// plugin page renders what is IN FORCE rather than making the
|
||||
// operator read .lua files or config.yaml to find out.
|
||||
"visibility": ps.VisibilityOverrides(),
|
||||
"http_access": ps.EndpointAccess(),
|
||||
})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := g.installPlugin(body.Name, body.Code); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// /api/plugins/{name} — source read (GET), enable/disable (PUT), delete.
|
||||
// Multi-segment paths (…/state) are handled earlier and never reach here.
|
||||
if !strings.Contains(path, "/") {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// Reading the SOURCE (not the runtime state) is what an editor
|
||||
// needs; the state endpoint is /state and returns accumulated data
|
||||
// instead. Mixing the two would make a "save what I read"
|
||||
// round-trip impossible.
|
||||
code, err := g.readPluginSource(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"name": path, "code": code,
|
||||
"enabled": g.core.Plugins().Enabled(path),
|
||||
})
|
||||
case http.MethodPut:
|
||||
// Enable / disable. The intent rides in the body rather than the
|
||||
// verb, because "enable" and "disable" are the same resource and
|
||||
// PUT /{name} is the one the docs advertise.
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if body.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", `body must be {"enabled":true|false}`)
|
||||
return
|
||||
}
|
||||
if err := g.core.Plugins().SetEnabled(path, *body.Enabled); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true, "name": path, "enabled": *body.Enabled,
|
||||
})
|
||||
case http.MethodDelete:
|
||||
if err := g.removePlugin(path); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusNotFound, "not_found", "unknown plugin sub-resource: "+path)
|
||||
}
|
||||
|
||||
// readPluginSource returns a plugin's file contents for the editor.
|
||||
func (g *Gateway) readPluginSource(name string) (string, error) {
|
||||
if err := validPluginName(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if g.pluginDir() == "" {
|
||||
return "", errNoPluginDir
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(g.pluginDir(), name+".lua"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no plugin source named %q", name)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// validPluginName rejects anything that could escape the plugin directory.
|
||||
// Shared by install / remove / read so the check cannot drift between them.
|
||||
func validPluginName(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, `/\.`) {
|
||||
return errPluginName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePluginState serves GET (read state) and PUT (replace state).
|
||||
func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name string) {
|
||||
ps := g.core.Plugins()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// The plugin's audience gates its DATA as well as its page. Hiding the UI
|
||||
// while leaving GET /state open would put the numbers one curl away, which
|
||||
// is the leak the audience field exists to close — and billing's state is
|
||||
// a global ledger, not the caller's own usage.
|
||||
//
|
||||
// A plugin that declares no audience is unchanged (every role may read
|
||||
// it), so a single-purpose widget keeps working without edits.
|
||||
role := reqRole(r.Context())
|
||||
if !ps.StateVisibleTo(name, role) {
|
||||
writeError(w, http.StatusForbidden, "forbidden",
|
||||
"plugin "+name+" is not visible to role "+role)
|
||||
return
|
||||
}
|
||||
// prices rides along: the billing rules editor must show the table
|
||||
// that is actually in effect, not a reconstruction from config. It is
|
||||
// absent for plugins with no prices (every plugin but billing).
|
||||
state := ps.State(name)
|
||||
// A plugin may SPLIT its state: global buckets stay deployment-wide while
|
||||
// per-caller buckets are narrowed to the authenticated key. Role
|
||||
// visibility alone cannot express this — the same role is allowed to read
|
||||
// the plugin, just not another caller's row.
|
||||
if scope := ps.StateScopeFor(name); scope != nil && role != "admin" {
|
||||
state = splitStateForKey(state, scope, reqKey(r.Context()))
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"plugin": name,
|
||||
"state": state,
|
||||
}
|
||||
if pr := ps.Prices(name); pr != nil {
|
||||
payload["prices"] = pr
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
case http.MethodPut:
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
var state interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := ps.SetState(name, state); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
|
||||
// Call invokes a named function on a plugin's Lua state and returns its result.
|
||||
//
|
||||
// This exists because plugin pages otherwise have NO way to reach their own
|
||||
// plugin code: pluginAPI offers state read/write and a couple of callbacks, and
|
||||
// that is enough for billing (whose numbers arrive on hooks during request
|
||||
// forwarding) but useless for a plugin that must do work on demand. A quota
|
||||
// page has to ask the vendor when the operator clicks Refresh, and no hook
|
||||
// fires at that moment.
|
||||
//
|
||||
// It is deliberately narrow:
|
||||
// - The function must be one the plugin explicitly lists in plugin.exports.
|
||||
// Anything else is refused, so this endpoint cannot become a generic "run
|
||||
// arbitrary Lua with my credentials" oracle over a plugin's private state.
|
||||
// - The caller must be admin, and the result is JSON — no Lua value is ever
|
||||
// handed back to Go as a live closure.
|
||||
// - Errors come back as (nil, message) from the plugin, matching every other
|
||||
// plugin entry point, and surface as a 502 rather than raising.
|
||||
func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name, fn string) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
return
|
||||
}
|
||||
var args map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
result, err := g.core.Plugins().Call(name, fn, args)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"result": result})
|
||||
}
|
||||
|
||||
// handlePluginFanout runs one plugin export per element of a list, in parallel,
|
||||
// from a SINGLE request.
|
||||
//
|
||||
// Why this exists: the quota page has twenty cards, and it used to fire twenty
|
||||
// browser requests at /call/. They arrived concurrently and then serialised
|
||||
// inside Plugins.Call, which holds the plugin mutex across the Lua function —
|
||||
// including its network wait. Measured on the live gateway: 20 probes took 29s of
|
||||
// wall clock while a single probe with no contention took 1.4–3.3s, so the page
|
||||
// sat on "probing…" for half a minute. The browser-side concurrency was real and
|
||||
// made no difference.
|
||||
//
|
||||
// A fan-out cannot be assembled from the client either: any request-per-item
|
||||
// arrangement is exactly the shape that queues. The parallelism has to happen
|
||||
// where the fan-out is received.
|
||||
func (g *Gateway) handlePluginFanout(w http.ResponseWriter, r *http.Request, name, fn string) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
// Args is a LIST of argument objects, one per call. A bare object is
|
||||
// rejected rather than coerced: "args" meaning "one call" and "args"
|
||||
// meaning "many" depending on its shape is how a caller ends up probing
|
||||
// one source and believing it probed twenty.
|
||||
Args []map[string]interface{} `json:"args"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Args) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "args must be a non-empty list of argument objects")
|
||||
return
|
||||
}
|
||||
// The page legitimately has one card per configured source. The bound is not
|
||||
// arbitrary: it stops one request from asking for thousands of VMs, and it is
|
||||
// enforced before any work starts rather than discovered shard by shard.
|
||||
if len(req.Args) > fanoutMaxArgs {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||||
fmt.Sprintf("args has %d entries, limit is %d", len(req.Args), fanoutMaxArgs))
|
||||
return
|
||||
}
|
||||
results, err := g.core.Plugins().ShardCall(name, fn, req.Args)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
// A shard that failed contributes a null in its slot. That is deliberate and
|
||||
// is NOT the same as the whole request failing: one unreachable upstream must
|
||||
// not turn a page of twenty cards into a single error.
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
||||
}
|
||||
|
||||
// fanoutMaxArgs bounds one fan-out request. Twenty is the measured real source
|
||||
// count on the gateway this was built for; the headroom is for operators with
|
||||
// more sources configured.
|
||||
const fanoutMaxArgs = 64
|
||||
|
||||
func (g *Gateway) pluginDir() string {
|
||||
if c := g.core.Config(); c != nil {
|
||||
return c.PluginDir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// installPlugin writes a plugin to disk and hot-loads it. A syntax error is
|
||||
// returned to the caller AND the file is left on disk so the operator can fix
|
||||
// it, matching how adapters behave (the file is authoritative once present).
|
||||
func (g *Gateway) installPlugin(name, code string) error {
|
||||
if err := validPluginName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := g.pluginDir()
|
||||
if dir == "" {
|
||||
return errNoPluginDir
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".lua"), []byte(code), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.core.Plugins().LoadSource(name, code)
|
||||
}
|
||||
|
||||
func (g *Gateway) removePlugin(name string) error {
|
||||
if err := validPluginName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.pluginDir() == "" {
|
||||
return errNoPluginDir
|
||||
}
|
||||
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
|
||||
return g.core.Plugins().Unload(name)
|
||||
}
|
||||
|
||||
// handleEndpointAccess serves the operator's view and edit surface for one
|
||||
// plugin endpoint's reachable keys.
|
||||
//
|
||||
// GET …/http/{path}/access the declared rules plus any override
|
||||
// PUT …/http/{path}/access body {"access": "admin,@homeagent"}
|
||||
// DELETE …/http/{path}/access clear the override
|
||||
//
|
||||
// The spec string is a comma list: a bare word is a role, "@name" is a gateway
|
||||
// key's configured name, and the literal "public" opens it to any
|
||||
// authenticated key. That is the same grammar the visibility override uses, and
|
||||
// keeping one grammar means an operator learns it once.
|
||||
//
|
||||
// Key names are validated against the configured keys BEFORE being stored. An
|
||||
// allowlist naming a key that does not exist is a silent no-op — every caller
|
||||
// is refused — and the operator would have no way to tell that apart from the
|
||||
// endpoint simply working.
|
||||
func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, name, epPath string) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
|
||||
// epPath "*" is the WHOLE-PLUGIN visibility override, not an endpoint. It
|
||||
// shares this route (and therefore this store) on purpose: an operator
|
||||
// setting "who can see this plugin" and "who can call this endpoint" is
|
||||
// asking the same question at two granularities, and two stores would let
|
||||
// them disagree. SetEndpointAccess cannot serve it — it validates that the
|
||||
// endpoint exists, which "*" by definition does not.
|
||||
if epPath == pluginWholeOverride {
|
||||
g.handlePluginVisibility(w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
for _, e := range ps.EndpointAccess() {
|
||||
if e.Plugin == name && e.Path == epPath {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"endpoint": e})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "no such endpoint")
|
||||
case http.MethodPut, http.MethodDelete:
|
||||
spec := ""
|
||||
if r.Method == http.MethodPut {
|
||||
var body struct {
|
||||
Access string `json:"access"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
spec = body.Access
|
||||
}
|
||||
if err := ps.ValidateAccessSpec(spec); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if err := ps.SetEndpointAccess(name, epPath, spec); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "access": spec})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
|
||||
// splitStateForKey rewrites a plugin state so that a non-admin caller sees only
|
||||
// the buckets that belong to them.
|
||||
//
|
||||
// The plugin declares which buckets are global and which are per-key. A per-key
|
||||
// bucket is keyed by the MASKED key ("***a1b2c3" — the same value the audit log
|
||||
// uses), so the caller's own row is found by masking the presented secret and
|
||||
// matching the last six characters.
|
||||
//
|
||||
// Keys that do not match are DROPPED, not zeroed: a zeroed row would render as
|
||||
// "you spent nothing" and, worse, would leak that a row exists for someone else.
|
||||
func splitStateForKey(state interface{}, scope *lua.StateScope, key string) interface{} {
|
||||
m, ok := state.(map[string]interface{})
|
||||
if !ok || scope == nil || key == "" {
|
||||
return state
|
||||
}
|
||||
masked := maskKey(key)
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
if !scope.IsGlobal(k) {
|
||||
// Per-key bucket: keep only the caller's rows, at every nesting level
|
||||
// the scope mentions (by_day_key is {day: {masked: bucket}}).
|
||||
out[k] = filterKeyBuckets(v, masked, scope.DepthOf(k))
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// filterKeyBuckets narrows a keyed structure to one masked key.
|
||||
//
|
||||
// depth 1 is {masked: bucket}; depth 2 is {day: {masked: bucket}}. The level is
|
||||
// declared rather than sniffed, because both shapes are dicts and sniffing would
|
||||
// confuse an empty bucket with a nested one.
|
||||
func filterKeyBuckets(v interface{}, masked string, depth int) interface{} {
|
||||
switch depth {
|
||||
case 0:
|
||||
return v // not a per-key bucket; leave alone
|
||||
case 1:
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
if row, ok := m[masked]; ok {
|
||||
return map[string]interface{}{masked: row}
|
||||
}
|
||||
return map[string]interface{}{}
|
||||
default:
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for day, inner := range m {
|
||||
im, ok := inner.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if row, ok := im[masked]; ok {
|
||||
out[day] = map[string]interface{}{masked: row}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// pluginWholeOverride is the epPath that addresses a plugin's overall visibility
|
||||
// rather than one of its endpoints.
|
||||
const pluginWholeOverride = "*"
|
||||
|
||||
// handlePluginVisibility is the whole-plugin half of the access API. The
|
||||
// audience string uses the same vocabulary as an endpoint spec (roles, @key
|
||||
// names, public) so the UI can drive both from one input.
|
||||
func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, name string) {
|
||||
ps := g.core.Plugins()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"plugin": name,
|
||||
"visibility": ps.VisibilityOverrides()[name],
|
||||
})
|
||||
case http.MethodPut, http.MethodDelete:
|
||||
spec := ""
|
||||
if r.Method == http.MethodPut {
|
||||
var body struct {
|
||||
Access string `json:"access"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
spec = body.Access
|
||||
}
|
||||
if err := ps.ValidateAccessSpec(spec); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
ps.SetPluginVisibility(name, spec)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "visibility": spec})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here")
|
||||
}
|
||||
}
|
||||
|
||||
// handlePluginKernel is the operator's surface for a plugin's kernel grants and
|
||||
// declared extra outbound hosts.
|
||||
//
|
||||
// GET returns what the plugin DECLARED beside what was GRANTED. That pairing is
|
||||
// the point of the endpoint: a plugin declaring `add_source` is a request, and
|
||||
// the operator needs to see the request next to the current answer to decide.
|
||||
func (g *Gateway) handlePluginKernel(w http.ResponseWriter, r *http.Request, name string) {
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
|
||||
return
|
||||
}
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
perm := ps.PluginKernelPermission(name)
|
||||
declared := []string(nil)
|
||||
for _, v := range ps.KernelPermissions() {
|
||||
if v.Plugin == name {
|
||||
declared = v.Declared
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"plugin": name,
|
||||
"actions": ps.KernelActionsView(name),
|
||||
"declared": declared,
|
||||
"granted": perm.Actions,
|
||||
"all_read": perm.AllRead,
|
||||
// Extra outbound hosts: declared vs granted, same request/privilege
|
||||
// split as kernel actions. What the plugin does with a host (login,
|
||||
// claim quotas...) is plugin business — the kernel only bounds egress.
|
||||
"hosts_declared": ps.PluginExtraHosts(name),
|
||||
"hosts_granted": ps.ExtraHostsGranted()[name],
|
||||
})
|
||||
return
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var body struct {
|
||||
Actions []string `json:"actions"`
|
||||
AllRead bool `json:"all_read"`
|
||||
Hosts []string `json:"hosts"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if err := ps.SetPluginKernelPermission(name, body.Actions, body.AllRead); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if body.Hosts != nil {
|
||||
if err := ps.GrantExtraHosts(name, body.Hosts); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET or PUT")
|
||||
}
|
||||
}
|
||||
@ -1,263 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"modelrouter/internal/config"
|
||||
"modelrouter/internal/core"
|
||||
"modelrouter/internal/lua"
|
||||
)
|
||||
|
||||
// gatewayWithBilling boots a gateway with the bundled billing plugin loaded, so
|
||||
// the UI-injection endpoint is exercised against a real plugin rather than a
|
||||
// hand-written stub. The other plugin tests in this package assert on the
|
||||
// WebUI source; this one asserts on the HTTP contract the browser consumes.
|
||||
func gatewayWithBilling(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
body := "listen: :0\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"gateway_keys:\n - sk-test\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
// Load the shipped plugin explicitly: seeding only runs for a directory that
|
||||
// does not exist yet, and this test wants a known plugin regardless.
|
||||
src, err := lua.ReadBundledPlugin("billing")
|
||||
if err != nil {
|
||||
t.Fatalf("read bundled billing: %v", err)
|
||||
}
|
||||
if err := c.Plugins().LoadSource("billing", src); err != nil {
|
||||
t.Fatalf("load billing: %v", err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// TestUIInjectServesPluginUI: GET /api/ui-inject is the single call the WebUI
|
||||
// makes at boot, and it must carry BOTH a contributed page and contributed
|
||||
// elements — the browser builds the sidebar from the page and mounts the
|
||||
// elements into existing panes from the same payload, so a partial response
|
||||
// would produce a page with no body or a missing tile.
|
||||
func TestUIInjectServesPluginUI(t *testing.T) {
|
||||
g := gatewayWithBilling(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var view struct {
|
||||
// Decoded into the real types so the test cannot drift from the wire
|
||||
// contract. An inline copy missed the `pages` field when the payload
|
||||
// shape changed and failed to compile, which is at least loud — but the
|
||||
// same copy also went on asserting the OLD single-page shape for a
|
||||
// release, silently.
|
||||
UI lua.UIExtension `json:"ui"`
|
||||
Stages []string `json:"stages"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// The payload carries every page in one list; ui.page is no longer a
|
||||
// separate slot (it was, and a second plugin contributing a page overwrote
|
||||
// whatever was there).
|
||||
var billing *lua.UIPage
|
||||
for i, pg := range view.UI.Pages {
|
||||
if pg != nil && pg.PageID == "billing" {
|
||||
billing = view.UI.Pages[i]
|
||||
}
|
||||
}
|
||||
if billing == nil {
|
||||
t.Fatalf("no billing page in the inject payload; pages=%d", len(view.UI.Pages))
|
||||
}
|
||||
if !strings.Contains(billing.Mount, "billing-root") {
|
||||
t.Error("the page mount came back empty")
|
||||
}
|
||||
if len(view.UI.Elements) == 0 {
|
||||
t.Error("billing contributes an element to the status page but it is missing")
|
||||
}
|
||||
for _, e := range view.UI.Elements {
|
||||
if e.Target != "status" {
|
||||
t.Errorf("element target = %q, want \"status\"", e.Target)
|
||||
}
|
||||
}
|
||||
// Derived from AllStages, not hardcoded: the previous assertion of "3"
|
||||
// is exactly what let chain_step go missing from this payload unnoticed.
|
||||
if len(view.Stages) != len(lua.AllStages) {
|
||||
t.Errorf("stages = %v, want %d (one per AllStages entry)", view.Stages, len(lua.AllStages))
|
||||
}
|
||||
for i, st := range lua.AllStages {
|
||||
if i >= len(view.Stages) || view.Stages[i] != string(st) {
|
||||
t.Errorf("stages[%d] = %v, want %q", i, view.Stages, string(st))
|
||||
}
|
||||
}
|
||||
if !containsStr(view.Stages, string(lua.StageChainStep)) {
|
||||
t.Error("the discovery payload does not advertise chain_step; a plugin " +
|
||||
"author would conclude the stage does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUIInjectIsEmptyWithoutPlugins: a gateway with no plugins must still answer
|
||||
// 200 with an empty (not missing, not null) payload. The WebUI calls this
|
||||
// unconditionally at boot, so a 404 or a null `ui` would break every dashboard.
|
||||
func TestUIInjectIsEmptyWithoutPlugins(t *testing.T) {
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/ui-inject", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"ui"`) {
|
||||
t.Error("no ui key in the response; the WebUI would have nothing to read")
|
||||
}
|
||||
}
|
||||
|
||||
// containsStr reports whether list has s.
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newRecorderFor pushes a request through the full handler chain.
|
||||
func newRecorderFor(t *testing.T, g *Gateway, req *http.Request) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
g.Handler().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// TestPluginsListAndStateAPI covers the management surface the plugin docs
|
||||
// promise: listing, and reading a plugin's own published state.
|
||||
func TestPluginsListAndStateAPI(t *testing.T) {
|
||||
g := gatewayWithBilling(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/plugins = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, want := range []string{"billing", "hook_errors", "plugin_dir", "request_end"} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Errorf("/api/plugins response lacks %q", want)
|
||||
}
|
||||
}
|
||||
// state read: the plugin published its (empty) state, so the key exists.
|
||||
rr = doReq(t, g, http.MethodGet, "/api/plugins/billing/state", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET state = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"total"`) {
|
||||
t.Errorf("billing state lacks the total bucket: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginStatePUTIsAdminOnly: only the WRITE side is gated. A user key must
|
||||
// be able to READ its own billing widget's data, but must not be able to
|
||||
// rewrite the price table.
|
||||
func TestPluginStatePUTIsAdminOnly(t *testing.T) {
|
||||
g := gatewayWithBilling(t)
|
||||
|
||||
// Build a user-role key and remember its secret.
|
||||
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userKey := rec.Key
|
||||
|
||||
// A user key may read the state.
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/plugins/billing/state", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userKey)
|
||||
rr := newRecorderFor(t, g, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("user GET state = %d, want 200 (the billing widget must render for users)", rr.Code)
|
||||
}
|
||||
|
||||
// A user key may NOT write it.
|
||||
put, _ := http.NewRequest(http.MethodPut, "/api/plugins/billing/state",
|
||||
strings.NewReader(`{"prices":{"default":{"prompt":0}}}`))
|
||||
put.Header.Set("Authorization", "Bearer "+userKey)
|
||||
put.Header.Set("Content-Type", "application/json")
|
||||
prr := newRecorderFor(t, g, put)
|
||||
if prr.Code != http.StatusForbidden {
|
||||
t.Errorf("user PUT state = %d, want 403 (a user must not rewrite the price table)", prr.Code)
|
||||
}
|
||||
|
||||
// An admin key may.
|
||||
adm := doReq(t, g, http.MethodPut, "/api/plugins/billing/state",
|
||||
`{"prices":{"default":{"prompt":1e-6}}}`)
|
||||
if adm.Code != http.StatusOK {
|
||||
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginsListCarriesAccessTables: the plugin page renders visibility
|
||||
// overrides and the endpoint access table from /api/plugins. Without them in the
|
||||
// response the operator has to open a .lua or config.yaml to learn who can see
|
||||
// what, which is exactly what the UI is meant to avoid.
|
||||
func TestPluginsListCarriesAccessTables(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/plugins = %d", rr.Code)
|
||||
}
|
||||
var out struct {
|
||||
Visibility map[string]string `json:"visibility"`
|
||||
HTTPAccess []map[string]interface{} `json:"http_access"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
|
||||
}
|
||||
// The calltest plugin registers no endpoints, so the table is present but
|
||||
// empty — the KEY is that the field exists at all. A missing field decodes to
|
||||
// nil and the UI silently renders nothing, which looks identical to "this
|
||||
// plugin has no endpoints".
|
||||
if !strings.Contains(rr.Body.String(), "http_access") {
|
||||
t.Error("/api/plugins response lacks the http_access field")
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "visibility") {
|
||||
t.Error("/api/plugins response lacks the visibility field")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginWholeVisibilityViaAccessAPI covers the "*" path: the plugin page
|
||||
// edits a plugin's overall audience through the same route it edits endpoints
|
||||
// with. Before this existed the UI had a visibility selector whose save 404'd,
|
||||
// because SetEndpointAccess validates that the endpoint exists and "*" does not.
|
||||
func TestPluginWholeVisibilityViaAccessAPI(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
||||
`{"access":"admin, user"}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT */access = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"ok":true`) {
|
||||
t.Errorf("unexpected body: %s", rr.Body.String())
|
||||
}
|
||||
// A user role is a legitimate audience; a nonsense one is not.
|
||||
bad := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
||||
`{"access":"../../etc"}`)
|
||||
if bad.Code != http.StatusBadRequest {
|
||||
t.Errorf("traversal-shaped audience = %d, want 400: %s", bad.Code, bad.Body.String())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user