mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
290 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91f6d1f32f | |||
| c1048dcd4e | |||
| 2b8fcaf434 | |||
| 47fe759025 | |||
| d795266572 | |||
| a82ae2b60e | |||
| 2d2c27c430 | |||
| 0f83fc048a | |||
| 1f29be43db | |||
| 8109acf7cd | |||
| 7c82c7ff6b | |||
| 1f18643d1f | |||
| e6fa2a678f | |||
| b871c0c713 | |||
| 8b318a8507 | |||
| 69da3c9b92 | |||
| 1be34557dd | |||
| 9e5fec08b8 | |||
| 68446fb6fe | |||
| 7e0a428c23 | |||
| 7a8d97001c | |||
| 850920155a | |||
| eaac0ef6b3 | |||
| 226493b5ae | |||
| d3a0eb6311 | |||
| cbad61a3ea | |||
| 2a2067284d | |||
| 742e2b5ed3 | |||
| 81b3661336 | |||
| 9521a35a03 | |||
| 252e3486bd | |||
| 0c56392d29 | |||
| f813ec714f | |||
| 7a6e03692d | |||
| 420379784b | |||
| 92c240cc25 | |||
| 5720e60fc9 | |||
| 42ec552c6d | |||
| 67e2ba070f | |||
| 29c87ce015 | |||
| 583976f7da | |||
| a020247627 | |||
| af8b93bd7c | |||
| 3819b00ded | |||
| 10ad4421f9 | |||
| f55d514e85 | |||
| faa3be5c37 | |||
| 8e2d384a7b | |||
| ff9b460eba | |||
| 1f54143956 | |||
| a7a0c6adee | |||
| 741edfea98 | |||
| ed1e250452 | |||
| fe9d5c6054 | |||
| 3e94047889 | |||
| 4a90099fb2 | |||
| 59229990ac | |||
| 6238265502 | |||
| 4bdde9cd61 | |||
| 70c24ba158 | |||
| c1733feeaa | |||
| 5b433b3266 | |||
| d1b7d94043 | |||
| 1586a051da | |||
| 41ff6d1be3 | |||
| 6fe6014d92 | |||
| 5ab047e48b | |||
| ec5e6dd57c | |||
| 705e079285 | |||
| 8949405746 | |||
| d86514c474 | |||
| d4ae2a1d13 | |||
| 652a035d47 | |||
| 6bec260513 | |||
| ed954b77bd | |||
| cb3ea25586 | |||
| 0232b9cd24 | |||
| a07449d32e | |||
| 84a9fccd9a | |||
| 63549db832 | |||
| d6d41209d9 | |||
| 3c59c33c58 | |||
| 5948ad19ca | |||
| 6b6f9526ff | |||
| 4d0efab86b | |||
| b6e687b567 | |||
| 2353d43c16 | |||
| 893857464d | |||
| 2f5cde7fea | |||
| da0282a3db | |||
| 6f2b6b8d3d | |||
| 96d97b0e0e | |||
| 6de9f28269 | |||
| 50997a4e3a | |||
| 74ec1e7874 | |||
| fa50ec8bc7 | |||
| c3b858d5ba | |||
| a59295e826 | |||
| dafc3b3fc3 | |||
| 66786bb0e3 | |||
| f4d5e779a5 | |||
| 61f402beef | |||
| 57fada3b0d | |||
| b6148e6f79 | |||
| 24e00cdc50 | |||
| 9016eda905 | |||
| 4d1407bbb4 | |||
| 5f6939b590 | |||
| a0ab9ddda5 | |||
| e4111337cd | |||
| 93eeba3762 | |||
| 958f1f48b5 | |||
| 9d6653a573 | |||
| 1c6a2821e4 | |||
| 3ad46ea23e | |||
| 6113924655 | |||
| d3203d88fc | |||
| 7836b95a84 | |||
| b6d0358079 | |||
| 0858a747ca | |||
| 7934507179 | |||
| f042bbf4af | |||
| 97a6cf9224 | |||
| d92db3a9fd | |||
| 0e747b0891 | |||
| be8d753e36 | |||
| 438629256d | |||
| a873fe4724 | |||
| 31bc3968c5 | |||
| c40b5b5981 | |||
| 7ce0378e8b | |||
| ae9f56ddb3 | |||
| 3860c7381f | |||
| 94c1e7688f | |||
| b8386465ea | |||
| f682205d26 | |||
| ba81a4d7e1 | |||
| e30199b29d | |||
| a1fddf72b7 | |||
| b9a025efba | |||
| 7c7cf3e078 | |||
| 18525ae4c0 | |||
| c4b37d4322 | |||
| 33b778faee | |||
| 345cbb30d6 | |||
| 501ad6aac9 | |||
| 39cda89def | |||
| 700f4179fa | |||
| 91e3013cb5 | |||
| 92b81ecfd3 | |||
| d119d09929 | |||
| 27b908d98c | |||
| ba10f03fc7 | |||
| cab3361073 | |||
| 35b9422232 | |||
| 2b34c7dc8a | |||
| 85c81c63cc | |||
| a2bb6de9fb | |||
| 0bf0653d00 | |||
| 48e7e6b2a5 | |||
| a21d0ca5a1 | |||
| cf14f66ad4 | |||
| 4e3b905b58 | |||
| 130b3f2e8d | |||
| dac0b4ce93 | |||
| 97864895d4 | |||
| f9cd0770a9 | |||
| 6311913812 | |||
| aa003e84d3 | |||
| 28149bdd1a | |||
| ed9baae8e4 | |||
| 13e2397e9c | |||
| 0cd1cd51f6 | |||
| 9dc2b64013 | |||
| c66e1b5955 | |||
| 22decf2bd3 | |||
| 4b07be67b4 | |||
| ba01da937b | |||
| 913974bb6c | |||
| c8edea23f5 | |||
| ffb2b8f2f2 | |||
| 689c8cb383 | |||
| 400b6c35a5 | |||
| c19b8e6394 | |||
| 09cb215208 | |||
| d9652f479a | |||
| fe0764e375 | |||
| d0ddc9754d | |||
| c241a19b51 | |||
| d1da40e493 | |||
| aa10ee8c27 | |||
| fbdf0dea10 | |||
| 30064696b3 | |||
| ce2032435a | |||
| 8de1499c40 | |||
| 1c690611f8 | |||
| a78f7cb6c5 | |||
| d0c7465130 | |||
| cb6df0a3f0 | |||
| 42764bc99e | |||
| 8c18e0c3d7 | |||
| a51a6811a6 | |||
| a2e1adc2d8 | |||
| 7082ffb723 | |||
| 5e723b5aa5 | |||
| e10bfbb278 | |||
| ad54616bee | |||
| 18e422a943 | |||
| 980f4a0e40 | |||
| 1fe379f630 | |||
| 429afce67e | |||
| b03b12148f | |||
| 5639bb662c | |||
| a1d4365515 | |||
| bf0657bb84 | |||
| 3548745f98 | |||
| 504c5ac9a0 | |||
| 70f1c879bd | |||
| 3fe0df5bf2 | |||
| 04e544c823 | |||
| 7e33d11d15 | |||
| 8ca59b4bb2 | |||
| cd82835f25 | |||
| 26ea782350 | |||
| 980408d045 | |||
| 5c58244781 | |||
| 0121d23f91 | |||
| 4b37e1a0db | |||
| de7c372ad2 | |||
| ebe60028f5 | |||
| c51066f0b6 | |||
| 5530912d32 | |||
| cc5e725226 | |||
| 652842783f | |||
| a21ae84cbe | |||
| d072a03c9a | |||
| f814ff7468 | |||
| ef631b43dd | |||
| 9c3aabb7f9 | |||
| a7355debed | |||
| f6baa13583 | |||
| fd03ef6e4a | |||
| 97bb9c6ef4 | |||
| d81074621a | |||
| 4dd3431c26 | |||
| 3d1407e6fa | |||
| db320d2f6c | |||
| b544671f5e | |||
| 0881a49286 | |||
| 4e2b5bfda9 | |||
| 61b847da86 | |||
| 6432b61eb6 | |||
| dd708c9680 | |||
| f0986751de | |||
| c6c3e0dcd7 | |||
| b9944332ff | |||
| 6bdb9fcc44 | |||
| 25eb30c15b | |||
| 131a42a169 | |||
| 3cdb16c906 | |||
| ef92ce4d82 | |||
| c524831616 | |||
| 868ac59692 | |||
| 4d197e4bd3 | |||
| 3806aaee03 | |||
| 5b628b4d6f | |||
| 2e3d5b79ad | |||
| 2ebc01e03b | |||
| 11776024e9 | |||
| bf1932c6c5 | |||
| 22ddf5a411 | |||
| 3698546d14 | |||
| ed05cccb72 | |||
| c309448414 | |||
| 5b6d6fc90d | |||
| 3ddae41f0c | |||
| 2378bc00ba | |||
| 882288f67f | |||
| 813de19bd0 | |||
| 25d8bd8632 | |||
| 3e27f4db24 | |||
| 23705042ee | |||
| 02cb8c0e33 | |||
| 6575058556 | |||
| d42c02b15d | |||
| df9aeed5fb | |||
| 21cd0429a2 | |||
| 2ed1f0ecde | |||
| 624fd74b45 | |||
| 94cbcb6771 |
9
.gitignore
vendored
9
.gitignore
vendored
@ -12,12 +12,21 @@ cmd/gui/bin/
|
||||
cmd/gui/build/gui-dist/
|
||||
cmd/build/
|
||||
cmd/gui/*.log
|
||||
cmd/gui/.cache/
|
||||
# workspace (AI-assisted coding local state)
|
||||
.pi/
|
||||
.pi-glla/
|
||||
.codegraph/
|
||||
.omo/
|
||||
/build/
|
||||
|
||||
# local ops scripts (machine-specific, not for sharing)
|
||||
scripts/
|
||||
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,10 +0,0 @@
|
||||
{
|
||||
"sessionID": "ses_fcda7f5e1ffe9g78A7lVoLMxn7",
|
||||
"updatedAt": "2026-08-24T11:19:43.949Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-08-24T11:19:43.949Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
.snapshots/chat_nonstream.json
Normal file
24
.snapshots/chat_nonstream.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
95
.snapshots/chat_stream.txt
Normal file
95
.snapshots/chat_stream.txt
Normal file
@ -0,0 +1,95 @@
|
||||
{
|
||||
"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]
|
||||
8
.snapshots/image_generation.json
Normal file
8
.snapshots/image_generation.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"created": <TS>,
|
||||
"data": [
|
||||
{
|
||||
"b64_json": "QUJD"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
Makefile
18
Makefile
@ -23,6 +23,14 @@ build:
|
||||
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) -----
|
||||
# Server admins install these with dpkg/rpm; unlike the GUI they are the bare
|
||||
# gateway binary + systemd unit (with memory tuning) + Lua adapters.
|
||||
core-dist:
|
||||
./packaging/core-dist.sh $(VERSION)
|
||||
|
||||
.PHONY: core-dist
|
||||
|
||||
# ---- GUI (Electron) ----
|
||||
gui:
|
||||
@cd $(GUI_DIR) && npm start
|
||||
@ -31,11 +39,12 @@ gui-dev:
|
||||
@cd $(GUI_DIR) && npm run dev
|
||||
|
||||
# build the embedded core into the GUI, then run electron-builder
|
||||
# targets: deb + AppImage (linux), rpm needs system rpmbuild
|
||||
# targets: deb + rpm + AppImage (linux); rpm needs system rpmbuild
|
||||
# NOTE: run in a clean env (no LD_LIBRARY_PATH pollution) so the packaged
|
||||
# binary does not link against host-only libs and crash on user machines.
|
||||
gui-dist:
|
||||
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:linux
|
||||
./packaging/verify-dist.sh
|
||||
|
||||
# deb only (for local sharing), rpm needs rpmbuild
|
||||
gui-deb:
|
||||
@ -48,6 +57,7 @@ gui-win:
|
||||
# windows nsis installer: full cross-build in docker (self-sufficient, no host mingw)
|
||||
gui-win-docker:
|
||||
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) ./scripts/dist-win-docker.sh
|
||||
./packaging/verify-dist.sh
|
||||
|
||||
gui-dist-dir:
|
||||
@cd $(GUI_DIR) && $(CLEAN_PACK_ENV) npm run dist:dir
|
||||
@ -56,15 +66,17 @@ test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) cmd/gui/build/gui-dist cmd/gui/bin
|
||||
rm -rf $(BUILD_DIR) cmd/build cmd/gui/dist cmd/gui/build/gui-dist cmd/gui/bin
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
@echo " make build build core binary (luajit)"
|
||||
@echo " make core-dist build core packages: deb + rpm + tar.gz (needs nfpm)"
|
||||
@echo " make gui run desktop GUI (dev)"
|
||||
@echo " make gui-dev run GUI with devtools"
|
||||
@echo " make gui-dist build deb + AppImage (linux)"
|
||||
@echo " make gui-dist build deb + rpm + AppImage (linux)"
|
||||
@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)"
|
||||
@echo " make test run go tests"
|
||||
@echo " make clean remove build dirs and packaged installers (build/, cmd/build/, cmd/gui/dist/)"
|
||||
|
||||
338
README.md
338
README.md
@ -15,15 +15,18 @@
|
||||
|
||||
### 极致轻量
|
||||
|
||||
- **单二进制**:编译后约 10MB,零运行时依赖(仅依赖系统 libc),部署即用
|
||||
- **极低内存占用**:空闲状态仅 ~15MB RSS,满载并发 100+ 请求时峰值 < 100MB
|
||||
- **单二进制**:编译后 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 配额、重置周期
|
||||
- **按模型配额**:每把 key 的每个模型单独配 token + 请求数配额与重置周期(小时/周/月/自定义 N 小时);一个模型用满只影响该模型,同 key 其它模型照常;耗尽返 429 + `Retry-After` 并点名模型,admin key 永不受限
|
||||
- **AUTO 智能调度**:基于优先级档位的分级调度,同优先级源自动轮询负载均衡,故障自动毫秒级故障转移
|
||||
- **自愈冷却**:冷却上限 5 分钟,过半后放行 1 个探测请求,上游/额度恢复即刻回归轮询,无需等满冷却窗口
|
||||
- **Token 配额管理**:精确到模型级别的 Token 配额控制,支持小时/周/月/自定义小时周期自动重置
|
||||
- **同优先级源负载均衡**:同一优先级档位的多个源,请求自动 Round-Robin 均匀分发,故障毫秒级故障转移
|
||||
- **Source-Model 前缀路由**:支持 `source-model`/`source:model`/`source/model` 精确指定上游源
|
||||
@ -37,7 +40,7 @@
|
||||
|
||||
### 灵活的协议适配
|
||||
|
||||
- **LuaJIT VM**:每个适配器独立 VM + worker 池,安全并发,Lua 脚本热加载无需重启
|
||||
- **LuaJIT VM**:每个适配器独立 VM + **弹性 worker 池**(按需扩缩容,空闲时自动回收),安全并发,Lua 脚本热加载无需重启
|
||||
- **Lua 适配器协议**:`transform_request` / `transform_response` / `transform_stream_chunk` 双向转换
|
||||
- **动态请求头钩子**:`build_headers(meta)` 支持 HMAC 签名、动态 Header 注入
|
||||
- **多模态透传**:`image_url` 等多模态内容在多源间无损透传,Anthropic/Gemini/Ollama 自动转换
|
||||
@ -60,7 +63,15 @@ Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换
|
||||
- **生图**:`POST /v1/images/generations`,`kind: image` 的模型独立路由。
|
||||
- **多模态**:`content` 数组(`image_url` 等)在多源间无损透传,Anthropic/Gemini/Ollama 自动转换。
|
||||
- **LuaJIT VM**:基于 golua 绑定的 LuaJIT,每个适配器独立 VM + worker 池,安全并发。
|
||||
- **disable_thinking**:请求 `disable_thinking:true`(或上游对应字段)动态开关推理。
|
||||
池是 **弹性** 的:`Σ 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 前
|
||||
@ -141,6 +152,47 @@ sources:
|
||||
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` 丢失后运行时密文无法解密。
|
||||
|
||||
@ -170,6 +222,81 @@ sources:
|
||||
- 客户端用任意一个已授权的密钥明文作为 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)"}}
|
||||
```
|
||||
|
||||
### 模型路由
|
||||
|
||||
`/v1/chat/completions` 的 `model` 解析顺序:
|
||||
@ -184,16 +311,51 @@ sources:
|
||||
|
||||
### AUTO 链(优先级页)
|
||||
|
||||
AUTO 调度**只**由优先级页保存的规则(持久化到 `runtime_file` 的 `auto` 字段)决定,
|
||||
源配置里的 `models[].priority` 数字不再参与调度、也不再显示。
|
||||
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 }`。
|
||||
|
||||
- 每条规则 = 一个「槽位」:`{ model, source, tier, token_quota, period, hours }`。
|
||||
- `tier` 表示优先级档位:同一档的模型并排、共享该优先级;档位从上到下递减。
|
||||
- 同一模型可配置多个槽位(如 A 源低配 → B 源低配 → A 源高配 → B 源高配),按档位顺延。
|
||||
- `token_quota` > 0 时该槽位在重置周期内用满即顺延到下一槽位;`period` 支持
|
||||
`hour` / `week` / `month` / `nhour`(配合 `hours`),空 = 不限。
|
||||
- 生图模型(`kind: image`)不参与 AUTO 链;生图走 `POST /v1/images/generations`
|
||||
的独立路径。
|
||||
- 同 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)的关系
|
||||
|
||||
@ -212,7 +374,7 @@ AUTO 调度**只**由优先级页保存的规则(持久化到 `runtime_file`
|
||||
2. `config.Load` 读取 `config.yaml`,`config.NewStore(runtime_file)` 读 WebUI 改动的
|
||||
运行时源,二者按名称合并成完整源列表。
|
||||
3. `rebuildRegistry` 为每个源创建 `provider.Provider`(持有目标适配器),并按源的
|
||||
并发上限配置适配器 worker 池大小。
|
||||
并发上限配置适配器 worker 池**上限**(不预分配,见下)。
|
||||
4. 请求进来时 `Registry` 按 `model` 路由到 Provider,Provider 调适配器
|
||||
`transform_request` → HTTP 发送 → `transform_response` / `transform_stream_chunk`。
|
||||
|
||||
@ -224,10 +386,77 @@ WebUI 上的"新增/编辑源"、"上传 Lua 适配器"、"改 AUTO 优先级链
|
||||
`config.yaml` 中定义的基源无法改写配置文件,采用删除标记隐藏(重启后仍隐藏),
|
||||
在 UI 里重新添加同名源即可恢复。
|
||||
|
||||
### disable_thinking
|
||||
### 源模板(多 key 负载均衡)
|
||||
|
||||
请求体带 `"disable_thinking": true` 时,网关透传给各适配器;DeepSeek 适配器将其
|
||||
映射为 `extra_body.thinking.type = "disabled"` 关闭推理,其余源按各自协议处理。
|
||||
多个上游 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 转激进,
|
||||
温和场景不必开。
|
||||
|
||||
### 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` 这类私有字段。
|
||||
|
||||
### WebUI
|
||||
|
||||
@ -237,7 +466,10 @@ WebUI 上的"新增/编辑源"、"上传 Lua 适配器"、"改 AUTO 优先级链
|
||||
支持按时间范围导出 CSV;点击模型可生成 pin 到该模型的连接配置。
|
||||
- **对话页**:流式/非流式调试。
|
||||
- **密钥页**:创建/编辑网关 key,为每个 key 配模型范围(模型 + 源 + token 配额 + 周期),
|
||||
管理员管理全部 key,用户只看到自己的 key。
|
||||
管理员管理全部 key,用户只看到自己的 key。每个模型砖块显示自己的配额徽标
|
||||
(如 `1.0K 77×·1h`,未设配额显示 `∞`),点开可编辑该模型的 token 配额、
|
||||
请求数配额与重置周期 —— 配额按模型独立生效,一个用满不影响同一 key 的其它模型。
|
||||
「我的密钥」页对用户展示本 key 的模型范围与各自配额。
|
||||
- **优先级页**:拖拽积木配置 AUTO 链档位。
|
||||
- **源页**:在线增删改上游源(API key 等敏感字段加密落盘)。
|
||||
- **适配器页**:上传 / 删除 Lua 适配器脚本。
|
||||
@ -272,6 +504,13 @@ return {
|
||||
-- [可选] 动态请求头/签名钩子
|
||||
-- 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,
|
||||
}
|
||||
```
|
||||
|
||||
@ -280,11 +519,12 @@ return {
|
||||
|
||||
### 内置适配器
|
||||
|
||||
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`。
|
||||
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`
|
||||
`qwen38` `sensenova` `trae`。
|
||||
|
||||
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);若
|
||||
源启用 `disable_thinking`,`deepseek` 适配器会把 `extra_body.thinking.type` 置为
|
||||
`disabled`。
|
||||
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);
|
||||
推理开关由中立的 `thinking` 字段表达,`deepseek` 适配器把它映射为
|
||||
`thinking.type = "disabled"`。
|
||||
|
||||
**kimicode** 是展示 `build_headers` 的样例:云端校验调用方 app,需要按
|
||||
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
|
||||
@ -296,14 +536,24 @@ return {
|
||||
一键启停、系统托盘常驻、开机自启、静默启动,内容区一比一内嵌完整 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 还是纯后端
|
||||
|
||||
| 场景 | 推荐 | 理由 |
|
||||
| ------ | ------ | ------ |
|
||||
| 服务器 / 内网网关 / 无人值守常驻 | **纯后端**(单二进制) | 极轻量(~10MB、~15MB RSS),零依赖单进程,直接跑在 systemd/任意容器里,远程管理 |
|
||||
| 个人桌面日常使用 / 多设备内网共享 | **GUI**(Electron) | 免登录内嵌 WebUI、系统托盘一键启停、开机自启、静默后台,适合不懂命令行的使用者 |
|
||||
| Windows 桌面 | **GUI** | 纯后端在 Windows 上需自行注册服务,GUI 提供原生托盘/自启体验 |
|
||||
| CI 一键出三平台安装包 | **GUI 打包脚本** | `make gui-*` 系列出 deb/AppImage/NSIS,可进发行流水线 |
|
||||
| 服务器 / 内网网关 / 无人值守常驻 | **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 版),
|
||||
配置/适配器格式完全一致,可随时互换。
|
||||
@ -315,27 +565,36 @@ cd cmd/gui
|
||||
npm install
|
||||
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/llmsproxy)
|
||||
make gui-deb # 本机构建 deb(分享给其它 Linux 用户)
|
||||
make gui-dist # 构建 deb + AppImage(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 自足,无需本机 mingw)
|
||||
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,含 wine)
|
||||
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
|
||||
```
|
||||
|
||||
产物在 `cmd/build/gui-dist/`:`ModelRouter-1.0.0.AppImage`、
|
||||
`modelrouter-gui_1.0.0_amd64.deb`、`ModelRouter Setup 1.0.0.exe`(win)。rpm 需系统 `rpmbuild`。
|
||||
产物在 `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 打包,宿主机**无需安装 mingw**。
|
||||
`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 +
|
||||
预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。LuaJIT 源码取自 gitcode
|
||||
镜像(国内可达),github 兜底;Go 依赖走 `https://goproxy.cn`。
|
||||
- 核心编译:`cmd/gui/scripts/win-core-docker.sh` —— 在容器内产出
|
||||
`cmd/gui/bin/{llmsproxy.exe,lua51.dll}`。
|
||||
- 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译后调用
|
||||
electron-builder(宿主需有 `wine` 用于 NSIS)。
|
||||
- 首次运行自动构建 `modelrouter/win-builder` 镜像;已存在则直接复用。
|
||||
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):
|
||||
@ -364,6 +623,15 @@ internal/gateway # OpenAI 兼容 HTTP 服务 + 鉴权 + SDK/流式 + Web
|
||||
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
|
||||
@ -371,4 +639,4 @@ go test -tags luajit ./...
|
||||
```
|
||||
|
||||
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
|
||||
模型路由、多模态透传与 disable_thinking。
|
||||
模型路由、多模态透传与 thinking 预算。
|
||||
|
||||
294
README_EN.md
294
README_EN.md
@ -11,7 +11,7 @@ KimiCode…) behind a single endpoint. Pick a specific model or use **AUTO** mod
|
||||
which routes to the best healthy upstream by configured priority.
|
||||
|
||||
> **Lightweight, no recompile to add sources**: the gateway is a single Go
|
||||
> binary (~10 MB, zero runtime dependencies). Adding or switching an upstream is
|
||||
> 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`
|
||||
@ -38,10 +38,31 @@ Extracted and independently evolved from the multi-source LLM adapter layer of
|
||||
`reasoning_content`, `tool_calls`, `usage`).
|
||||
- **Image generation**: `POST /v1/images/generations`, routed to models with
|
||||
`kind: image`.
|
||||
- **Per-model quota**: each key gives every model its own token and request caps
|
||||
plus a reset period (hour/week/month/custom N hours). One model running out
|
||||
affects only that model — the key's other models keep working. Exhaustion
|
||||
answers 429 + `Retry-After` naming the model, so a client resumes when the
|
||||
window rolls over; admin keys are never capped.
|
||||
- **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.
|
||||
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
|
||||
@ -155,11 +176,103 @@ under the `keys` field of the runtime file (encrypted at rest):
|
||||
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).
|
||||
scope** (model + source + token quota + reset period). Each model brick shows
|
||||
its own budget badge (e.g. `1.0K 77×·1h`, `∞` when uncapped); clicking it
|
||||
edits that model's token quota, request quota and reset period. Quotas apply
|
||||
per model, so one model running out never blocks the key's others. The
|
||||
"My key" view shows a user its model scopes and their budgets.
|
||||
- Clients authenticate with any authorized key's plaintext as
|
||||
`Authorization: Bearer <key>`.
|
||||
- Deleting a key removes it from the store immediately.
|
||||
|
||||
#### Per-key usage quota
|
||||
|
||||
**Quotas are per model.** Each key's `models[]` list gives every model its own
|
||||
token budget and request budget. One model running out affects only that model
|
||||
— the key's other models keep working.
|
||||
|
||||
```yaml
|
||||
keys:
|
||||
- key: sk-gw-<hex>
|
||||
role: user
|
||||
name: agent-alice
|
||||
models:
|
||||
- model: deepseek-v4-flash
|
||||
token_quota: 1000000 # this key's token budget for this model
|
||||
req_quota: 20000 # requests within the window
|
||||
period: nhour # "" | hour | week | month | nhour
|
||||
hours: 6 # n-hour only
|
||||
- model: AUTO
|
||||
token_quota: 5000000 # AUTO is a quota entry like any other
|
||||
period: hour
|
||||
- model: kimi-k3 # no quota listed = unlimited
|
||||
```
|
||||
|
||||
- **There is deliberately no key-wide total.** A key-wide cap would make
|
||||
"move A's budget to B" a global reallocation; per-model budgets keep each
|
||||
model independently controllable, so it stays visible which model is
|
||||
actually consuming the spend.
|
||||
- `period`: empty = never resets (lifetime total); `hour` / `week` / `month` =
|
||||
fixed windows; `nhour` + `hours` = a custom hour count. **A misspelled
|
||||
period is rejected at write time** rather than silently becoming a
|
||||
never-resetting quota.
|
||||
- Quotas are isolated per key *and* per model within a key: one key exhausting
|
||||
`m1` never draws on another key's budget, and never blocks the same key's
|
||||
`m2`.
|
||||
- Usage counts chat, streaming and image requests, and survives a restart by
|
||||
replaying the audit log (40 days retained, covering the longest `month`
|
||||
window).
|
||||
- An exhausted quota returns **429 + `Retry-After`**
|
||||
(`rate_limit_exceeded`) and the message names the model that ran out, so a
|
||||
client resumes when the window rolls over; a model the key may not use stays
|
||||
403. **Admin keys are never capped** (quotas on their scopes are not
|
||||
enforced either), so a cap can never lock the operator out.
|
||||
- Buckets are whole unix hours, so a window frees up at most an hour late
|
||||
(deliberately freeing late rather than overspending).
|
||||
- `PUT /api/keys/{key}` submits quotas by submitting `models` — the caps are
|
||||
part of the scope, so there is no second budget that can drift out of sync
|
||||
with the model list. An explicit `0` lifts that model's cap.
|
||||
|
||||
##### Quota rejection vs capacity rejection
|
||||
|
||||
A quota is checked at the entry point and occupies no upstream slot, so it is a
|
||||
cheap rejection. Running out of capacity instead waits out the bounded
|
||||
`busyWait` before answering 503. Measured (capacity 8, 0.6 s/request, 100
|
||||
concurrent clients):
|
||||
|
||||
| Case | Status | Latency | Reaches upstream |
|
||||
|---|---|---|---|
|
||||
| Quota spent (`req_quota` 50, 100 concurrent) | `429 rate_limit_exceeded` + `Retry-After` | **19–21 ms** | no |
|
||||
| Capacity exhausted (same topology, no quota) | `503 upstream_error` | ~2.6 s (`busyWait`) | overflow: no |
|
||||
|
||||
So a client can tell "wait for the window to roll over" apart from "wait for
|
||||
upstream capacity" — the first only needs patience, the second usually means
|
||||
back off concurrency or switch sources.
|
||||
|
||||
##### What the quota costs
|
||||
|
||||
Buckets are kept per (key, model, whole unix hour) for 40 days. Measured on an
|
||||
AMD 7840HS:
|
||||
|
||||
- A window query scans the window, not the whole retention: 49 ns for 1 h,
|
||||
55 ns for 24 h, 3.9 µs for 30 d.
|
||||
- Recording one request: 283 ns, 3 allocations (unchanged from before this
|
||||
feature; the allocations come from the record ring buffer).
|
||||
- Memory: the production shape (7 keys x 8 models x 2 sources at full 40-day
|
||||
retention) costs about **3.7 MB**. The `source::model` bucket used by a
|
||||
source-pinned quota is created **lazily** — it is maintained only once some
|
||||
quota actually pins that source, because writing it on every record would
|
||||
cost an extra 18 MB at 20 keys x 8 models x 3 sources.
|
||||
The trade-off: usage recorded before a pinned quota was configured cannot be
|
||||
split by source afterwards, so **a pinned quota may under-count its first
|
||||
window**.
|
||||
|
||||
```
|
||||
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)"}}
|
||||
```
|
||||
|
||||
### Model routing
|
||||
|
||||
`/v1/chat/completions` `model` resolution order:
|
||||
@ -176,12 +289,20 @@ tiered AUTO chain.
|
||||
|
||||
### AUTO chain (Priority page)
|
||||
|
||||
AUTO scheduling is driven **only** by the rules saved on the Priority page
|
||||
(persisted as the `auto` field of the runtime file). The numeric
|
||||
`models[].priority` in source configs no longer participates in scheduling and
|
||||
is no longer shown.
|
||||
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 }`.
|
||||
|
||||
- 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 →
|
||||
@ -189,8 +310,9 @@ is no longer shown.
|
||||
- `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.
|
||||
- Image models (`kind: image`) are kept out of the chain and are served by the
|
||||
separate `POST /v1/images/generations` path.
|
||||
- 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
|
||||
|
||||
@ -227,6 +349,82 @@ 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
|
||||
@ -289,7 +487,7 @@ Shared helpers: `hmac_sha256_hex(key, data)`, `sha256_hex(data)`,
|
||||
### Built-in adapters
|
||||
|
||||
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama`
|
||||
`kimicode`.
|
||||
`kimicode` `qwen38` `sensenova` `trae`.
|
||||
|
||||
`anthropic`/`gemini`/`ollama` include multimodal conversion
|
||||
(`image_url` → their native format); with `disable_thinking` the `deepseek`
|
||||
@ -308,14 +506,25 @@ 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 | **plain backend** (single binary) | ~10 MB, ~15 MB RSS, zero-dep single process — drop it into systemd or any container, remote admin |
|
||||
| Personal desktop daily use / multi-device intranet sharing | **GUI** (Electron) | no-login embedded WebUI, tray one-click, autostart, silent background — for non-CLI users |
|
||||
| Windows desktop | **GUI** | plain backend needs manual service registration; GUI ships native tray/autostart |
|
||||
| CI one-shot 3-platform installers | **GUI packaging scripts** | `make gui-*` emits deb / AppImage / NSIS, drops straight into a release pipeline |
|
||||
| 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.
|
||||
@ -327,30 +536,42 @@ 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 + AppImage (linux)
|
||||
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 (no host mingw needed)
|
||||
make gui-win-docker # Windows NSIS, fully dockerized (wine in image; no host mingw/node/wine)
|
||||
```
|
||||
|
||||
Artifacts land in `cmd/build/gui-dist/`: `ModelRouter-1.0.0.AppImage`,
|
||||
`modelrouter-gui_1.0.0_amd64.deb`, `ModelRouter Setup 1.0.0.exe` (win). rpm
|
||||
needs system `rpmbuild`.
|
||||
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 in one step; the host needs
|
||||
**no mingw**.
|
||||
`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 +
|
||||
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`.
|
||||
- Core build: `cmd/gui/scripts/win-core-docker.sh` — emits
|
||||
`cmd/gui/bin/{llmsproxy.exe,lua51.dll}` inside the container.
|
||||
- One-shot release: `cmd/gui/scripts/dist-win-docker.sh` — after the core,
|
||||
runs electron-builder (host needs `wine` for NSIS).
|
||||
- The `modelrouter/win-builder` image is auto-built on first run and reused
|
||||
afterwards.
|
||||
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):
|
||||
@ -383,6 +604,17 @@ internal/gateway # OpenAI-compatible HTTP + auth + SDK/streaming + WebUI
|
||||
internal/types # unified format & OpenAI wire types
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
# ModelRouter GUI Windows builder
|
||||
# Self-sufficient cross-compilation environment for the embedded llmsproxy core.
|
||||
# Produces llmsproxy.exe (Go, windows/amd64, luajit tag) + lua51.dll (LuaJIT mingw build)
|
||||
# inside a mounted workspace: cmd/gui/bin/{llmsproxy.exe,lua51.dll}
|
||||
# Self-sufficient cross-compilation environment for the embedded llmsproxy core
|
||||
# AND for packaging the Windows NSIS installer.
|
||||
#
|
||||
# Produces inside a mounted workspace:
|
||||
# - llmsproxy.exe (Go, windows/amd64, luajit tag) + lua51.dll
|
||||
# - ModelRouter Setup <ver>.exe (electron-builder --win nsis; requires wine
|
||||
# so the uninstaller is generated — set USE_SYSTEM_WINE=true)
|
||||
#
|
||||
# Build: docker build -t modelrouter/win-builder cmd/gui/docker/win-builder
|
||||
# Usage: make gui-win-docker (or cmd/gui/scripts/dist-win-docker.sh)
|
||||
#
|
||||
# This image is the SINGLE wine provider for the NSIS step, so a host that has
|
||||
# no / broken wine (e.g. amd64-only Debian: no syswow64 -> c0000135) can still
|
||||
# produce a real installer. The NSIS step MUST run inside this image; running it
|
||||
# on the host was how the 1.3.0 264 KB degenerate exe got shipped.
|
||||
#
|
||||
# LuaJIT source is fetched from gitcode.com (CN-reachable mirror) with github
|
||||
# fallback; pass --build-arg LUAJIT_REPO to override.
|
||||
|
||||
@ -13,11 +22,26 @@ FROM golang:1.25
|
||||
|
||||
ARG LUAJIT_REPO=https://gitcode.com/openresty/luajit2.git
|
||||
|
||||
RUN apt-get update -qq \
|
||||
# i386 arch is required for wine32 (the NSIS uninstaller step runs a win32 exe
|
||||
# through wine's wow64 path, which needs the 32-bit runtime under /usr/lib/i386).
|
||||
RUN dpkg --add-architecture i386 \
|
||||
&& apt-get update -qq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
gcc-mingw-w64-x86-64 make ca-certificates git \
|
||||
# NSIS step needs node + the electron-builder toolchain
|
||||
nodejs npm \
|
||||
# wine (32+64) for generating the NSIS uninstaller
|
||||
wine64 wine32:i386 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# electron-builder resolves the uninstaller via the system wine when
|
||||
# USE_SYSTEM_WINE=true (or by default on Linux without a toolset). Pin it.
|
||||
# The toolchain cache lives in $HOME/.cache (mounted as a volume by
|
||||
# dist-win-docker.sh), so it is reused across builds and never pollutes the
|
||||
# mounted workspace.
|
||||
ENV USE_SYSTEM_WINE=true
|
||||
ENV WINEDEBUG=-all
|
||||
|
||||
# LuaJIT cross-compiled for Windows; keeps lua51.dll and the cgo import lib
|
||||
# (libluajit-5.1.dll.a -> libluajit-5.1.a in the mingw lib dir).
|
||||
# NOTE: $LUAJIT_REPO deliberately unquoted in "sh -c" — quoting the ARG would
|
||||
|
||||
190
cmd/gui/main.js
190
cmd/gui/main.js
@ -17,7 +17,7 @@ const fs = require("fs");
|
||||
const http = require("http");
|
||||
const os = require("os");
|
||||
const crypto = require("crypto");
|
||||
const { spawn } = require("child_process");
|
||||
const { spawn, execFileSync } = require("child_process");
|
||||
|
||||
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
|
||||
function log(msg) {
|
||||
@ -97,24 +97,99 @@ 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 "";
|
||||
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,
|
||||
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(
|
||||
CORE_EXE,
|
||||
["-config", CONFIG_FILE, "-show-secrets"],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
cwd: PROFILE_DIR,
|
||||
},
|
||||
);
|
||||
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 "";
|
||||
// -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;
|
||||
}
|
||||
}
|
||||
|
||||
const embeddedBaseUrl = () =>
|
||||
@ -198,6 +273,17 @@ function startCore() {
|
||||
cwd: PROFILE_DIR,
|
||||
env: Object.assign({}, process.env, {
|
||||
LLMS_PROXY_GUI: "1",
|
||||
// Memory tuning, measured on a 16-source deployment (see README 内存占用):
|
||||
// MALLOC_ARENA_MAX caps glibc per-thread malloc arenas. LuaJIT allocates
|
||||
// through cgo -> glibc malloc, and glibc defaults to 8*nproc arenas, so
|
||||
// every OS thread touching malloc reserves its own ~1 MB arena that is
|
||||
// never returned to the OS.
|
||||
// GOGC halves the Go heap growth target. On its own it does not help (the
|
||||
// saved heap is immediately eaten by extra glibc arenas); only the pair
|
||||
// lowers settled RSS. The core is I/O bound, so the extra GC is free.
|
||||
// Both are overridable: a user who exports them keeps their own values.
|
||||
GOGC: process.env.GOGC || "50",
|
||||
MALLOC_ARENA_MAX: process.env.MALLOC_ARENA_MAX || "2",
|
||||
}),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@ -585,6 +671,78 @@ 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,
|
||||
|
||||
8
cmd/gui/package-lock.json
generated
8
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.9.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.9.3",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
@ -582,7 +582,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/webcrypto": {
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz",
|
||||
"integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==",
|
||||
"dev": true,
|
||||
@ -3335,7 +3335,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/resedit": {
|
||||
"version": "1.7.2",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
"integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==",
|
||||
"dev": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.9.7",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
@ -10,7 +10,7 @@
|
||||
"dev": "electron . --no-sandbox --dev",
|
||||
"core:linux": "node scripts/prepare-core.js linux",
|
||||
"core:win": "node scripts/prepare-core.js win",
|
||||
"dist:linux": "npm run core:linux && electron-builder --linux deb AppImage",
|
||||
"dist:linux": "npm run core:linux && electron-builder --linux deb rpm AppImage",
|
||||
"dist:debian": "npm run core:linux && electron-builder --linux deb",
|
||||
"dist:win": "npm run core:win && electron-builder --win nsis",
|
||||
"dist:dir": "npm run core:linux && electron-builder --dir"
|
||||
@ -22,7 +22,7 @@
|
||||
"build": {
|
||||
"appId": "com.modelrouter.gui",
|
||||
"productName": "ModelRouter",
|
||||
"linux": {
|
||||
"linux": {
|
||||
"target": [
|
||||
"deb",
|
||||
"rpm",
|
||||
@ -32,9 +32,9 @@
|
||||
"icon": "build/icon.png",
|
||||
"maintainer": "ModelRouter",
|
||||
"synopsis": "ModelRouter Desktop",
|
||||
"desktop": {
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"StartupWMClass": "modelrouter-gui"
|
||||
"StartupWMClass": "modelrouter-gui"
|
||||
}
|
||||
},
|
||||
"extraResources": [
|
||||
|
||||
@ -16,6 +16,13 @@ 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),
|
||||
|
||||
@ -155,6 +155,7 @@ async function openSettings() {
|
||||
$("#set-tray").checked = !!state.settings.minimizeToTray;
|
||||
$("#settings-overlay").style.display = "flex";
|
||||
renderRail();
|
||||
loadPlugins();
|
||||
}
|
||||
function closeSettings() {
|
||||
$("#settings-overlay").style.display = "none";
|
||||
@ -182,6 +183,296 @@ 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;
|
||||
@ -197,6 +488,12 @@ 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 = () => {
|
||||
|
||||
@ -207,6 +207,16 @@
|
||||
><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>
|
||||
|
||||
@ -596,3 +596,129 @@ 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,23 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot Windows release build for the GUI:
|
||||
# One-shot Windows release build for the GUI — ENTIRELY inside docker.
|
||||
#
|
||||
# 1) cross-compile the embedded core in docker (llmsproxy.exe + lua51.dll)
|
||||
# 2) host-side electron-builder NSIS packaging (wine required on host)
|
||||
# 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
|
||||
# (no syswow64 -> c0000135), which made electron-builder silently emit a
|
||||
# 264 KB degenerate exe without its 86 MB payload.
|
||||
#
|
||||
# Outputs: cmd/build/gui-dist/"ModelRouter Setup <version>.exe"
|
||||
# cmd/build/gui-dist/modelrouter-gui-<version>-x64.nsis.7z
|
||||
#
|
||||
# Needs: docker (any machine; no host mingw/wine/node required)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
cd "$ROOT/cmd/gui"
|
||||
IMAGE="${WIN_BUILDER_IMAGE:-modelrouter/win-builder}"
|
||||
PROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||||
CACHE_VOL="modelrouter-eb-cache"
|
||||
|
||||
# 1) core (docker)
|
||||
"$ROOT/cmd/gui/scripts/win-core-docker.sh"
|
||||
command -v docker >/dev/null || { echo "docker required"; exit 1; }
|
||||
|
||||
# 2) packaging (host, needs wine for electron-builder's signtool/nsis)
|
||||
command -v wine >/dev/null || { echo "wine required on host for NSIS packaging"; exit 1; }
|
||||
if ! command -v npx >/dev/null; then echo "node/npm required"; exit 1; fi
|
||||
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "[dist-win-docker] building image $IMAGE (first run, ~5-10 min)..."
|
||||
docker build -t "$IMAGE" "$ROOT/cmd/gui/docker/win-builder"
|
||||
fi
|
||||
|
||||
echo "[dist-win] electron-builder --win nsis ..."
|
||||
# bin artifacts already refreshed by win-core-docker.sh; skip the prepare step
|
||||
npx electron-builder --win nsis
|
||||
docker volume create "$CACHE_VOL" >/dev/null 2>&1 || true
|
||||
|
||||
ls -la "$ROOT/cmd/build/gui-dist/"*.exe 2>/dev/null
|
||||
echo "[dist-win] done"
|
||||
# 1) embedded core + 2) NSIS packaging — both inside the image so wine and node
|
||||
# are the image's, never the host's. electron-builder downloads electron + nsis
|
||||
# toolchains into ~/.cache on first run; the mounted volume keeps that warm so
|
||||
# subsequent builds are incremental instead of re-fetching ~400 MB.
|
||||
echo "[dist-win-docker] cross-compiling core + electron-builder NSIS in docker ..."
|
||||
docker run --rm \
|
||||
-v "$ROOT":/workspace \
|
||||
-v "$CACHE_VOL":/root/.cache \
|
||||
-e GOPROXY="$PROXY" \
|
||||
-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 \
|
||||
-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
|
||||
# prefix in the cache volume so it is initialized only on the first run.
|
||||
export WINEPREFIX=/root/.cache/wineprefix
|
||||
if [ ! -f "$WINEPREFIX/drive_c/windows/syswow64/ntdll.dll" ]; then
|
||||
echo " -> initializing wine prefix (first run)"
|
||||
WINEDEBUG=-all wineboot -i || true
|
||||
fi
|
||||
cd /workspace/cmd/gui
|
||||
# node_modules ships in the repo (host npm install already ran); reuse it.
|
||||
npx electron-builder --win nsis
|
||||
'
|
||||
|
||||
echo "[dist-win-docker] verifying artifacts ..."
|
||||
"$ROOT/packaging/verify-dist.sh" "$ROOT/cmd/build/gui-dist"
|
||||
|
||||
echo "[dist-win-docker] done"
|
||||
@ -23,8 +23,39 @@ import (
|
||||
|
||||
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("[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
|
||||
}
|
||||
|
||||
// -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("[llmsproxy] show-secrets: %v", err)
|
||||
}
|
||||
if err := config.PrintSecrets(*cfgPath); err != nil {
|
||||
log.Fatalf("[llmsproxy] show-secrets: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
created, err := config.EnsureDefault(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[llmsproxy] config: %v", err)
|
||||
@ -42,19 +73,26 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
gw, err := gateway.New(c, c.GatewayKeys())
|
||||
gw, err := gateway.New(c)
|
||||
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")
|
||||
//
|
||||
// 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("[llmsproxy] 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 == "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 k.Key == "sk-gw-local-0001" || k.Key == "sk-local-0001" {
|
||||
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -88,4 +126,4 @@ func main() {
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Printf("[llmsproxy] shutting down")
|
||||
}
|
||||
}
|
||||
|
||||
401
deploy.sh
401
deploy.sh
@ -10,6 +10,16 @@
|
||||
# 4. systemctl restart → 服务中断仅在 Go 进程重启瞬间 (<500ms)
|
||||
# 5. 健康检查确认新进程正常
|
||||
#
|
||||
# 配置文件(2026-09-05 起):
|
||||
# 本脚本不再假设 config.yaml 与部署无关。实测教训:给源加 headers 时
|
||||
# 漏删原有的 `headers: {}` → YAML 重复键 → 进程启动即退出 → 健康检查
|
||||
# 失败 → 旧的 rollback 只恢复二进制,配置错误仍在,于是旧二进制也起不来,
|
||||
# 服务陷入 systemd 重启循环(当时累计 446 次)。
|
||||
# 因此:
|
||||
# - 支持 --config <file> 投放一份待部署配置(原子 rename 到 TARGET_CONFIG)
|
||||
# - 重启前先用新二进制 `-check` 预检配置,不合法就地失败,绝不重启服务
|
||||
# - rollback 同时恢复二进制与配置文件,回滚后再次 -check 才重启
|
||||
#
|
||||
# 注意事项:
|
||||
# - 本服务依赖自身(推理依赖),restart 前后会短暂中断,
|
||||
# 但重试机制(systemd RestartSec=5 + 客户端 retry)可自愈
|
||||
@ -19,17 +29,58 @@ set -euo pipefail
|
||||
|
||||
# ---------- 配置 ----------
|
||||
readonly TARGET_BIN="/usr/local/bin/llmsproxy"
|
||||
readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml"
|
||||
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
|
||||
readonly TARGET_PLUGINS="/etc/llmsproxy/plugins"
|
||||
readonly REPO_DIR="/home/program/llmsproxy"
|
||||
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
||||
readonly BIN_NAME="llmsproxy"
|
||||
|
||||
# 构建目录在 /tmp(tmpfs 更快)
|
||||
readonly TMP_DIR="/tmp/llmsproxy-deploy"
|
||||
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
|
||||
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
|
||||
# 可用 LLMSPROXY_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
|
||||
readonly BUILD_SUBDIR=".build-work"
|
||||
if [[ -n "${LLMSPROXY_BUILD_DIR:-}" ]]; then
|
||||
readonly TMP_DIR="${LLMSPROXY_BUILD_DIR}"
|
||||
else
|
||||
readonly TMP_DIR="${REPO_DIR}/${BUILD_SUBDIR}"
|
||||
fi
|
||||
# 同文件系统暂存文件(用于原子 rename)
|
||||
readonly STAGING_BIN="${TARGET_BIN}.staging"
|
||||
# 回滚备份:健康检查失败时从这里恢复旧二进制
|
||||
readonly STAGING_CONFIG="${TARGET_CONFIG}.staging"
|
||||
# 回滚备份:健康检查失败时从这里恢复旧二进制 / 旧配置
|
||||
readonly BACKUP_BIN="${TARGET_BIN}.bak"
|
||||
readonly BACKUP_CONFIG="${TARGET_CONFIG}.rollback.bak"
|
||||
|
||||
# 待部署配置(--config <file> 指定;为空 = 不改动线上配置)
|
||||
NEW_CONFIG_SRC=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: ./deploy.sh [--config <file>]
|
||||
|
||||
--config <file> 投放这份配置到 /etc/llmsproxy/config.yaml(原子替换)。
|
||||
省略时不改动线上配置,只部署二进制与适配器。
|
||||
无论是否指定,重启前都会用新二进制 -check 预检线上配置。
|
||||
|
||||
流程: 构建 → 适配器同步 → 备份(二进制+配置) → 原子替换 → 配置预检
|
||||
→ 重启 → 健康检查 → 失败则回滚(二进制+配置)并重启
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--config)
|
||||
[[ $# -ge 2 ]] || { echo "--config 需要一个文件参数" >&2; exit 2; }
|
||||
NEW_CONFIG_SRC="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "未知参数: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ---------- 日志 ----------
|
||||
log() { printf '\033[1;34m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
@ -40,7 +91,7 @@ fail() { err "$@"; exit 1; }
|
||||
# ---------- 清理 ----------
|
||||
cleanup() {
|
||||
log "清理临时文件"
|
||||
rm -rf "$TMP_DIR" "$STAGING_BIN"
|
||||
rm -rf "$TMP_DIR" "$STAGING_BIN" "$STAGING_CONFIG"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@ -52,6 +103,18 @@ precheck() {
|
||||
command -v systemctl &>/dev/null || fail "未找到 systemctl"
|
||||
[[ $(id -u) -eq 0 ]] || fail "需要 root 权限"
|
||||
|
||||
if [[ -n "$NEW_CONFIG_SRC" ]]; then
|
||||
[[ -f "$NEW_CONFIG_SRC" ]] || fail "待部署配置不存在: $NEW_CONFIG_SRC"
|
||||
# 同一文件比对:--config 指向线上文件本身时没有"投放"动作可做,
|
||||
# 但仍然会走重启前的 -check 预检。
|
||||
if [[ "$(readlink -f "$NEW_CONFIG_SRC")" == "$(readlink -f "$TARGET_CONFIG" 2>/dev/null)" ]]; then
|
||||
warn "--config 指向线上配置本身,跳过投放(仍会预检)"
|
||||
NEW_CONFIG_SRC=""
|
||||
else
|
||||
log "待部署配置: $NEW_CONFIG_SRC → $TARGET_CONFIG"
|
||||
fi
|
||||
fi
|
||||
|
||||
OLD_SIZE=$(stat -c%s "$TARGET_BIN" 2>/dev/null || echo 0)
|
||||
OLD_SHA256=$(sha256sum "$TARGET_BIN" 2>/dev/null | awk '{print $1}')
|
||||
|
||||
@ -59,15 +122,24 @@ precheck() {
|
||||
log "go: $(go version)"
|
||||
}
|
||||
|
||||
# ---------- 备份旧二进制(用于回滚)----------
|
||||
backup_old_binary() {
|
||||
# ---------- 备份旧二进制 + 旧配置(用于回滚)----------
|
||||
# 配置也必须备份:启动失败常常是配置问题,只回滚二进制会让旧二进制
|
||||
# 继续吃坏配置,服务照样起不来。
|
||||
backup_old_state() {
|
||||
log "备份旧二进制到 $BACKUP_BIN"
|
||||
if [[ -f "$TARGET_BIN" ]]; then
|
||||
cp -f "$TARGET_BIN" "$BACKUP_BIN"
|
||||
log "✓ 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
|
||||
log "OK 旧二进制已备份 (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))"
|
||||
else
|
||||
warn "线上配置不存在: $TARGET_CONFIG(首次部署?无配置回滚点)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- 构建 ----------
|
||||
@ -77,7 +149,7 @@ build() {
|
||||
mkdir -p "$TMP_DIR/bin"
|
||||
|
||||
cd "$REPO_DIR"
|
||||
CGO_ENABLED=1 go build -tags luajit \
|
||||
CGO_ENABLED=1 GOTMPDIR="$TMP_DIR" go build -tags luajit \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$TMP_DIR/bin/$BIN_NAME" \
|
||||
@ -89,8 +161,13 @@ build() {
|
||||
if [[ "$NEW_SIZE" -eq "$OLD_SIZE" ]]; then
|
||||
NEW_SHA256=$(sha256sum "$NEW_BIN" | awk '{print $1}')
|
||||
if [[ "$NEW_SHA256" == "$OLD_SHA256" ]]; then
|
||||
warn "新二进制与旧版本完全一致(sha256 相同),跳过部署"
|
||||
exit 0
|
||||
# 二进制无变化。只有在同时也没有待部署配置时才能直接退出;
|
||||
# 否则会造成“--config 指定了新配置却被静默丢弃”。
|
||||
if [[ -z "$NEW_CONFIG_SRC" ]]; then
|
||||
warn "新二进制与旧版本完全一致(sha256 相同)且无待部署配置,跳过部署"
|
||||
exit 0
|
||||
fi
|
||||
log "二进制无变化(sha256 相同),但有待部署配置,继续"
|
||||
fi
|
||||
fi
|
||||
|
||||
@ -105,9 +182,9 @@ build() {
|
||||
)
|
||||
for c in "${checks[@]}"; do
|
||||
if strings "$NEW_BIN" | grep -qF "$c"; then
|
||||
log " ✓ 新二进制包含: $c"
|
||||
log " OK 新二进制包含: $c"
|
||||
else
|
||||
warn " ⚠ 新二进制不包含: $c(非预期但非致命)"
|
||||
warn " 注意: 新二进制不包含: $c(非预期但非致命)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
@ -137,7 +214,172 @@ atomic_replace_binary() {
|
||||
|
||||
# 验证
|
||||
NEW_SHA256=$(sha256sum "$TARGET_BIN" | awk '{print $1}')
|
||||
log "✓ 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
|
||||
log "OK 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
|
||||
}
|
||||
|
||||
# ---------- 原子替换配置 ----------
|
||||
# 与二进制同一套 copy→rename 手法:先落到 /etc 上的 .staging,再 rename(2)。
|
||||
# 只有 --config 指定了来源时才动线上配置。
|
||||
atomic_replace_config() {
|
||||
[[ -n "$NEW_CONFIG_SRC" ]] || return 0
|
||||
|
||||
log "原子替换配置"
|
||||
log " copy → $STAGING_CONFIG (同文件系统暂存)"
|
||||
cp -f "$NEW_CONFIG_SRC" "$STAGING_CONFIG"
|
||||
chmod 0644 "$STAGING_CONFIG"
|
||||
|
||||
log " rename → $TARGET_CONFIG (原子)"
|
||||
mv -f "$STAGING_CONFIG" "$TARGET_CONFIG"
|
||||
log "OK 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
}
|
||||
|
||||
# ---------- 配置预检 ----------
|
||||
# 用刚部署的新二进制解析线上配置。`-check` 只做 parse + ApplyDefaults,
|
||||
# 不启动 Lua VM、不碰 runtime.json、不绑端口,因此可以安全地在服务仍在
|
||||
# 运行时执行。不合法就在重启前失败——这正是上次事故缺的那一步。
|
||||
verify_config() {
|
||||
log "配置预检(新二进制 -check)"
|
||||
[[ -f "$TARGET_CONFIG" ]] || fail "线上配置不存在: $TARGET_CONFIG"
|
||||
|
||||
local out
|
||||
if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then
|
||||
log "OK 配置合法"
|
||||
return 0
|
||||
fi
|
||||
|
||||
err "配置预检失败,未重启服务(线上进程仍在跑旧配置):"
|
||||
printf '%s\n' "$out" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 密钥可解封校验 ----------
|
||||
# config.yaml 里的凭据以 enc:v1: 密文落盘,解密钥匙是 master.key(或
|
||||
# LLMS_PROXY_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"
|
||||
|
||||
if [[ -n "${LLMS_PROXY_MASTER_KEY:-}" ]]; then
|
||||
log " 钥匙来源: LLMS_PROXY_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 "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:"
|
||||
printf '%s\n' "$out" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$key_file" ]]; then
|
||||
err "配置含 $sealed 处密文,但钥匙文件不存在: $key_file"
|
||||
err " 没有它,部署后服务将无法启动。恢复 master.key 或设置 LLMS_PROXY_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/llmsproxy 因此涨到 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/llmsproxy"
|
||||
[[ -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
|
||||
}
|
||||
|
||||
# ---------- 同步适配器 ----------
|
||||
@ -157,6 +399,7 @@ sync_adapters() {
|
||||
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
|
||||
@ -171,14 +414,75 @@ sync_adapters() {
|
||||
grep -qE '\buses\b' "$TARGET_ADAPTERS/$f.lua" 2>/dev/null && ((usage_fixed++)) || true
|
||||
done
|
||||
log " usage 透传修复适配器: ${usage_fixed}/7 (openai/deepseek/github/groq/kimicode/mistral/opencode)"
|
||||
log "✓ 适配器已同步到 $TARGET_ADAPTERS/"
|
||||
log "OK 适配器已同步到 $TARGET_ADAPTERS/"
|
||||
}
|
||||
|
||||
# 同步内置插件到 plugin_dir。
|
||||
#
|
||||
# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器
|
||||
# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份),
|
||||
# 保留目录里运行时新增/运维自加的 .lua。
|
||||
#
|
||||
# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口:
|
||||
# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
|
||||
# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版,
|
||||
# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。
|
||||
#
|
||||
# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置
|
||||
# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。
|
||||
sync_plugins() {
|
||||
log "同步内置插件"
|
||||
|
||||
SRC_PLUGINS="$REPO_DIR/internal/lua/plugins"
|
||||
[[ -d "$SRC_PLUGINS" ]] || fail "内嵌插件目录不存在: $SRC_PLUGINS"
|
||||
|
||||
mkdir -p "$TARGET_PLUGINS"
|
||||
|
||||
# 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动)
|
||||
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local added=0 updated=0 same=0
|
||||
for src in "$SRC_PLUGINS"/*.lua; do
|
||||
[[ -f "$src" ]] || continue
|
||||
local name; name="$(basename "$src")"
|
||||
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
|
||||
if cmp -s "$src" "$TARGET_PLUGINS/$name"; then
|
||||
((same++)) || true
|
||||
continue
|
||||
fi
|
||||
cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " ~ 更新内置插件: $name"
|
||||
((updated++)) || true
|
||||
else
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " + 新增内置插件: $name"
|
||||
((added++)) || true
|
||||
fi
|
||||
done
|
||||
# 没有任何改动时不必留空备份目录
|
||||
if (( updated == 0 && added == 0 )); then
|
||||
rmdir "$BACKUP_DIR" 2>/dev/null || true
|
||||
else
|
||||
log " 旧插件已备份到 $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
|
||||
for src in "$SRC_PLUGINS"/*.lua; do
|
||||
[[ -f "$src" ]] || continue
|
||||
local name; name="$(basename "$src")"
|
||||
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
|
||||
done
|
||||
log "OK 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
|
||||
}
|
||||
|
||||
# ---------- 重启服务 ----------
|
||||
restart_service() {
|
||||
log "重启服务(中断 <500ms)"
|
||||
systemctl restart llmsproxy.service || fail "systemd restart 失败"
|
||||
log "✓ llmsproxy.service restarted"
|
||||
log "OK llmsproxy.service restarted"
|
||||
}
|
||||
|
||||
# ---------- 健康检查 ----------
|
||||
@ -193,7 +497,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 "✓ 服务健康 (status=$code, ${attempt}/${attempts})"
|
||||
log "OK 服务健康 (status=$code, ${attempt}/${attempts})"
|
||||
return 0
|
||||
fi
|
||||
log " 等待中 ($attempt/$attempts, status=$code)"
|
||||
@ -206,20 +510,49 @@ healthcheck() {
|
||||
}
|
||||
|
||||
# ---------- 回滚 ----------
|
||||
# 健康检查失败时恢复旧二进制并重启,保证推理服务可用。
|
||||
# 健康检查失败时恢复旧二进制**和旧配置**并重启,保证推理服务可用。
|
||||
# 这是最后一道保险:即使新版本启动失败(编译错误、配置不兼容、
|
||||
# panic on start 等),也能让旧版本继续服务,避免推理能力丢失。
|
||||
#
|
||||
# 为什么必须一并回滚配置:启动失败最常见的原因就是配置本身不合法
|
||||
# (实测:YAML 重复键)。只恢复二进制的话,旧二进制照样解析不了坏配置,
|
||||
# 服务继续在 systemd 重启循环里空转。
|
||||
rollback() {
|
||||
err "健康检查失败,启动回滚流程"
|
||||
if [[ ! -f "$BACKUP_BIN" ]]; then
|
||||
err "无回滚备份 ($BACKUP_BIN 不存在),无法自动恢复"
|
||||
|
||||
local restored_any=0
|
||||
if [[ -f "$BACKUP_BIN" ]]; then
|
||||
log "恢复旧二进制"
|
||||
cp -f "$BACKUP_BIN" "$TARGET_BIN"
|
||||
chmod 0755 "$TARGET_BIN"
|
||||
restored_any=1
|
||||
else
|
||||
err "无二进制回滚备份 ($BACKUP_BIN 不存在)"
|
||||
fi
|
||||
|
||||
# 只在本次真的换过配置时才回滚配置,避免把用户在部署期间
|
||||
# 通过 WebUI 做的合法改动一起抹掉。
|
||||
if [[ -n "$NEW_CONFIG_SRC" && -f "$BACKUP_CONFIG" ]]; then
|
||||
log "恢复旧配置"
|
||||
cp -f "$BACKUP_CONFIG" "$TARGET_CONFIG"
|
||||
chmod 0644 "$TARGET_CONFIG"
|
||||
restored_any=1
|
||||
fi
|
||||
|
||||
if (( restored_any == 0 )); then
|
||||
err "无任何回滚点,无法自动恢复"
|
||||
err "请手动检查: systemctl status llmsproxy.service && journalctl -u llmsproxy -n 50"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "恢复旧二进制"
|
||||
cp -f "$BACKUP_BIN" "$TARGET_BIN"
|
||||
chmod 0755 "$TARGET_BIN"
|
||||
# 回滚后先验证恢复出来的组合真的能解析,再重启——否则只是把
|
||||
# 重启循环换个二进制继续跑。
|
||||
if ! "$TARGET_BIN" -check -config "$TARGET_CONFIG" >/dev/null 2>&1; then
|
||||
err "回滚后的配置仍不合法,拒绝重启(避免 systemd 重启风暴)"
|
||||
err " 手动修复 $TARGET_CONFIG 后执行: $TARGET_BIN -check -config $TARGET_CONFIG"
|
||||
err " 备份可用: 二进制=$BACKUP_BIN 配置=$BACKUP_CONFIG"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "重启服务(旧版本)"
|
||||
systemctl restart llmsproxy.service || {
|
||||
@ -233,7 +566,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 "✓ 回滚成功,旧版本已恢复服务 (status=$code)"
|
||||
log "OK 回滚成功,旧版本已恢复服务 (status=$code)"
|
||||
log " 新版本部署失败,请检查构建/配置后重试"
|
||||
return 0
|
||||
else
|
||||
@ -248,18 +581,38 @@ print_summary() {
|
||||
log "═════════════════════════════════════════════"
|
||||
log "部署完成"
|
||||
log " 二进制: $TARGET_BIN"
|
||||
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
|
||||
log " 适配器: $TARGET_ADAPTERS"
|
||||
log " 插件: $TARGET_PLUGINS"
|
||||
log "═════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
# ---------- 主流程 ----------
|
||||
# 顺序要点:配置投放与预检都排在 restart_service **之前**,
|
||||
# 于是坏配置在服务仍然健康时就被拦下,根本不会触发重启。
|
||||
main() {
|
||||
parse_args "$@"
|
||||
log "═══ llmsproxy 原子部署开始 ═══"
|
||||
precheck
|
||||
# 密钥校验放在 build/替换之前:它的失败后果最重(配置里的密文永久解不开),
|
||||
# 必须在动任何文件之前就挡住。此阶段用的是**当前线上二进制**,它已经
|
||||
# 支持 -show-secrets(首次引入该子命令的版本除外,此时跳过并提示)。
|
||||
verify_master_key
|
||||
build
|
||||
sync_adapters
|
||||
backup_old_binary
|
||||
sync_plugins
|
||||
backup_old_state
|
||||
atomic_replace_binary
|
||||
atomic_replace_config
|
||||
if ! verify_config; then
|
||||
# 配置不合法:服务还没重启,只需把二进制/配置退回原状。
|
||||
err "配置预检失败,回退本次改动(服务未受影响)"
|
||||
[[ -f "$BACKUP_BIN" ]] && cp -f "$BACKUP_BIN" "$TARGET_BIN" && chmod 0755 "$TARGET_BIN"
|
||||
if [[ -n "$NEW_CONFIG_SRC" && -f "$BACKUP_CONFIG" ]]; then
|
||||
cp -f "$BACKUP_CONFIG" "$TARGET_CONFIG" && chmod 0644 "$TARGET_CONFIG"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
restart_service
|
||||
if ! healthcheck; then
|
||||
rollback || true
|
||||
|
||||
540
docs/channels-parity.md
Normal file
540
docs/channels-parity.md
Normal file
@ -0,0 +1,540 @@
|
||||
# channels 插件与参考插件的三线对齐表
|
||||
|
||||
参考仓:`Ebony-Vinyl/dsh-our-free-model`(本地克隆
|
||||
`/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model`),关键实现位于
|
||||
`vendor/channel-pack/src/`(144 个 TS 文件)。
|
||||
|
||||
命名规律(照它逐厂商对齐的三条线):
|
||||
|
||||
| 后缀 | 职责 | 我们要对齐的东西 |
|
||||
|---|---|---|
|
||||
| `X-auth.ts` / `X-oauth.ts` / `login.ts` | **登录** | 登录流程、端点、参数、凭据形状 |
|
||||
| `X-product.ts` / `models.ts` | **模型获取** | 远端端点、解析路径、兜底表 |
|
||||
| `X-adapter.ts` / `llm-adapter.ts` | **接入模型服务** | chat 端点、载荷转换、鉴权头 |
|
||||
|
||||
## 一、登录(line ①)
|
||||
|
||||
| 平台 | 参考流程 | 我们的 flow | 状态 |
|
||||
|---|---|---|---|
|
||||
| trae | OAuth 17 参数 + 设备身份派生 | callback | ✅ 逐字节对齐(含 login_trace_id / machine_id / x_device_*) |
|
||||
| minimax | device-poll | device-poll | ✅ |
|
||||
| cline | device-poll | device-poll | ✅ |
|
||||
| buddy | state-poll(**无回调**) | state-poll | ✅ |
|
||||
| gemini | OAuth + client_secret | callback | ✅(含 client_secret、localhost 主机、6 scope) |
|
||||
| lobsterai | 本地回调 + authCode 换 token | callback | ✅(含动态版本号、3 步活动领取) |
|
||||
| opencode | paste | paste | ✅(参考本身即粘贴凭据) |
|
||||
| **codearts** | **PKCE + DPoP ES256 + STS 换 AK/SK** | **callback** | ✅ **本轮修复**(此前误标 paste) |
|
||||
| qoder | device-poll + 设备身份 | device-poll | ✅(含 Cosy-MachineToken/Type 成对) |
|
||||
| zcode | init-poll | init-poll | ✅ |
|
||||
| loomy | 微信扫码 poll | wechat-poll | ✅ |
|
||||
| raccoon | 二维码 poll | qr-poll | ✅ |
|
||||
|
||||
### codearts 登录(本轮修复的重点)
|
||||
|
||||
此前标成 `flow="paste"`(手填凭据)是**错的**。参考 `login.ts` / `oauth.ts`
|
||||
默认走**新式 IAM OAuth**:
|
||||
|
||||
1. 起本地回调服务器,端口须 **≥10000**(`MIN_CALLBACK_PORT`,低于此 portal 拒绝),
|
||||
路径 `/oauth/callback`
|
||||
2. 打开 `https://codearts.huaweicloud.com/portal/authorize`,参数逐项:
|
||||
`theme=2`、`locale=zh-cn`、`uri_scheme=codearts-agent`、`client_id=codearts-agent`、
|
||||
`code_challenge_method=**SHA-256**`(不是 RFC 的 S256 —— 错值会让 portal
|
||||
**回退旧 ticket 流程**,看起来"也能用"但不是 OAuth)、`ticket_id`、
|
||||
`plugin-name=snap_AIIDE`、`plugin-version=5.2.0`;
|
||||
**不得**含 `auth_callback_url`(会被判异常并回退)
|
||||
3. 回调收到 `code` 后,带 **DPoP 证明**向
|
||||
`https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens` 换
|
||||
`credentials{access_key_id, secret_access_key, security_token, expiration}`
|
||||
+ `refresh_token`
|
||||
4. 凭据持久化须含续期材料:`refresh_token` / `code_verifier` / `dpop_private_key_jwk`
|
||||
|
||||
DPoP 细节:ES256 JWS,`header={alg,typ:dpop+jwt,jwk}`、
|
||||
`payload={htm,htu,iat,jti}`;**签名是 raw r||s 64 字节**,而 openssl 输出 DER
|
||||
——必须转换格式,否则服务端验签失败而字符串看起来完全正常。
|
||||
|
||||
终态判定只认 `invalid_grant` / `ErrorCode 含 ExpiredRefreshToken`;
|
||||
**不认** `InvalidDPoPHeader`(那是一次请求层面的拒绝:时钟偏差、proof 重放、
|
||||
网关抖动都会触发,当终态会把完好账号一步标死)。
|
||||
|
||||
旧 ticket 流程(`CODEARTS_LOGIN_BASE` + `/snap-manager/v1/login/ticket` 轮询)
|
||||
保留作回退。
|
||||
|
||||
## 二、模型获取(line ②)
|
||||
|
||||
远端优先、兜底补位的机制已具备(`models_remote` + `effectiveModels`)。
|
||||
本轮把**兜底表**逐平台补成参考全量:
|
||||
|
||||
| 平台 | 参考来源 | 兜底数 | 状态 |
|
||||
|---|---|---|---|
|
||||
| trae | `TRAE_FALLBACK_MODELS` | 28 可见 + 4 hidden | ✅(hidden 单列 `models_hidden`) |
|
||||
| minimax | `GET /mavis/api/v1/models?region=cn&buildEnv=prod` | 4 | ✅ |
|
||||
| cline | `GET /api/v1/models` + `recommended-models` | 3 | ✅ |
|
||||
| buddy | (无独立表) | 7 | ✅ |
|
||||
| gemini | — | 2 | ✅ |
|
||||
| lobsterai | `GET /api/models/available`(**须带 model_flag**) | 19 | ✅ |
|
||||
| opencode | `GET /v1/models` | 14 | ✅(含 `models_free`) |
|
||||
| codearts | opengw `gateway/config` + `/v1/model/builtin` | 3 | ⚠️ 见下 |
|
||||
| qoder | `/api/v2/model/list`(需 WASM 签名) | 19 | ✅(含 CN 独有 q37fmodel/gm51model) |
|
||||
| zcode | `GET /api/v1/client/configs` | 2 | ✅ |
|
||||
| loomy | `GET /api/v1/models`(取 `type==='chat'`) | 8 | ✅ |
|
||||
| raccoon | 远端 + 兜底 | 6 | ✅ |
|
||||
|
||||
**trae 的 `isHidden` 语义**:`browser_use_subagent` / `explore_sub_agent_v13` /
|
||||
`explore_sub_agent_v2` / `summary` 是内部子 agent,不进用户可选列表。
|
||||
`model_list` 已按此过滤(`models_hidden`)。
|
||||
|
||||
**opencode 的 `isFree`**:参考明确标注 7 个模型匿名可用、其余需付费 key。
|
||||
此前 `model_list` 把 `isFree` 硬编码为 false,等于把官方免费模型标成付费。
|
||||
|
||||
### codearts 模型获取(未闭环)
|
||||
|
||||
参考 `models.ts` 从两个端点拉:
|
||||
- `OPENGW_GATEWAY_CONFIG_URL`(`opengw.developer.huaweicloud.com/api/v1/gateway/config`)
|
||||
→ `result.models`,这是 **benefit(免费额度)模型**权威来源
|
||||
- `SNAP_MODEL_BUILTIN_URL`(`/v1/model/builtin`)→ `builtinModels`,常规模型
|
||||
|
||||
两者都要 **AK/SK 的 SDK-HMAC-SHA256 签名**。另有 `normalizeModelId`
|
||||
(去尾部 `-NNNN` 日期版本)与 `isCodeArtsBenefitModel`(决定 chat 是否带
|
||||
`maas_type: benefit`,不带则 404 `model is not registered`)。
|
||||
**这部分尚未实现**——我们的 codearts 目前只有 3 个硬编码模型名。
|
||||
|
||||
## 三、接入模型服务(line ③)
|
||||
|
||||
**本轮发现的最严重问题**:源注册时 `base_url + adapter.endpoint` 拼出的
|
||||
chat URL **全部缺路径前缀**,11 个平台里 11 个都不对。已逐个修正:
|
||||
|
||||
| 平台 | 正确 chat 端点 | 修正前 |
|
||||
|---|---|---|
|
||||
| trae | `…/api/agent/v3/llm_utils_chat` | `…/chat/completions` |
|
||||
| cline | `api.cline.bot/api/v1/chat/completions` | `api.cline.bot/chat/completions` |
|
||||
| lobsterai | `…/api/proxy/v1/chat/completions` | `…/chat/completions` |
|
||||
| qoder | `api2-v2.qoder.sh/model/v1/chat/completions` | `…/chat/completions` |
|
||||
| buddy | `copilot.tencent.com/v2/chat/completions` | `…/chat/completions` |
|
||||
| codearts | `…/api/v2/chat/completions` | `…/chat/completions` |
|
||||
| opencode | `opencode.ai/zen/v1/chat/completions` | `opencode.ai/zen/chat/completions` |
|
||||
| raccoon | `…/api/web/llm/v2/chat/completions` | `…/chat/completions` |
|
||||
| loomy | `loomy.xunfei.cn/chat/completions` | `loomy.xunfei.cn` |
|
||||
| zcode | `zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages` | `zcode.z.ai/v1/messages` |
|
||||
| gemini | `/v1internal:generateContent`(私有动词) | `/v1beta/models/{model}:generateContent`(公开,凭据不匹配) |
|
||||
|
||||
### ✅ 已闭环:三个私有协议平台改用**插件自装适配器**
|
||||
|
||||
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
|
||||
`LlmAdapter` 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。
|
||||
内核其实**早已实现** `__llmsproxy_adapters.install`(含归属校验/冲突仲裁/
|
||||
测试),但 channels 插件对该全局的引用数是 **0**。现已第一次真正使用:
|
||||
|
||||
| 平台 | 自装适配器 | 解决的协议差异 |
|
||||
|---|---|---|
|
||||
| codearts | `codearts` | 华为云 SDK-HMAC-SHA256 **AK/SK 签名**(canonical/string-to-sign 逐字节) |
|
||||
| trae | `channels-trae` | **SOLO 载荷**:config_name 双字段、function 通道、tool_calls→function_call、parameters→JSON 字符串、tool_choice 归一化 |
|
||||
| gemini | `channels-gemini` | 私有 `/v1internal:` **信封** + 身份五头 + 字母序序列化 + 响应剥一层 |
|
||||
|
||||
命名说明:trae 用 `channels-trae` 而非 `trae`,因为内置 `trae.lua` 服务的是
|
||||
**运维自建的 trae-local-api 反代**(`127.0.0.1:19900`,生产 config.yaml 在用),
|
||||
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)——
|
||||
判据里专门断言内置 `trae` 未被顶掉。
|
||||
|
||||
**新增能力:`credential_shape`**。签名平台需要**完整凭据 JSON**(适配器自己
|
||||
解 AK/SK),其余平台交裸 access token。此前 `syncAccountSource` 一律用
|
||||
`credentialOf` 取 `access_token`,于是 codearts 凭据(只有
|
||||
`access_key_id`/`secret_access_key`/`security_token`)取不到 token →
|
||||
**静默 return、不出源**。现已按 `plat.credential_shape` 区分。
|
||||
|
||||
**另一处静默失败已修**:`syncAccountSource` 拿到内核 `add_source` 的失败
|
||||
(权限未授、形状不符)时不记日志,表现为"账号加了但不出源",排查零线索。
|
||||
现补失败日志(区分内核 err 与 `json.ok==false`)。
|
||||
|
||||
### 仍未闭环:gemini 的 sigstore(明确不做)
|
||||
|
||||
参考 `gemini-sigstore.ts`(268 行)持久化 tool-call 签名,供跨请求回填。
|
||||
我们推理走**无状态适配器**,插件侧缓存会成为死代码。签名缺失时上游会拒,
|
||||
适配器如实透传错误。理由与结论已记在此处,避免反复讨论。
|
||||
|
||||
## 四、明确不做的(附理由)
|
||||
|
||||
| 参考模块 | 行数 | 为什么不做 |
|
||||
|---|---|---|
|
||||
| `gemini-sigstore.ts` | 268 | 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔ |
|
||||
| `qoder-wasm.ts` | 1442 | 需随插件分发 298KB wasm 二进制;B 类,待立项 |
|
||||
| `zcode-captcha.ts` | 2212 | 阿里云滑块,需真实浏览器/验证码链路;B 类 |
|
||||
| `captcha-*.ts` | 1223 | 同上 |
|
||||
|
||||
## 五、本机实测踩到的坑(都已修)
|
||||
|
||||
1. **`xxd` 不存在** —— `command -v xxd` 为空,用它取十六进制会**静默得到空串**。
|
||||
改用 `od -An -tx1`。
|
||||
2. **`openssl ec -text` 会截断 32 字节 priv**(实测只给 30 字节)→ `d` 必须用
|
||||
`openssl asn1parse` 的 `[HEX DUMP]`;`tail -c 32` 拿到的是 SEC1 结构里
|
||||
**公钥的 y**,不是 `d`。
|
||||
3. **Lua local 前向引用**:`local function` 只从定义处向后可见。`hexToBin` /
|
||||
`b64url` / `b64urlRaw` 被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块
|
||||
**之前**(本文件累计踩过 6 次)。
|
||||
4. **`min_port` 参数读到 `argv[2]`** 与 `out_path` 冲突 —— 回调脚本的第 5 个
|
||||
参数才是 minPort。
|
||||
5. **Lua 双引号串里的 `\1`** 是转义坑(`sed 's/…/…/\1/'` 会报
|
||||
invalid escape sequence)→ 改用 `awk -F': ' '{print $NF}'`。
|
||||
|
||||
---
|
||||
|
||||
# 为什么参考插件是"正常"的:架构差异(2026-10-13 查证)
|
||||
|
||||
用户问「为什么他们就正常」。查完参考仓的架构后,答案是**三层结构差异**,
|
||||
不是某一个端点的笔误。
|
||||
|
||||
## 参考的架构:一个平台 = 一个适配器类
|
||||
|
||||
参考依赖宿主 SDK 的 seam:`import { LlmAdapter } from '@deepseek-ai/dsh-llm'`。
|
||||
每个平台有一个**继承基类的真类**,同时拥有登录、模型目录、推理三件事:
|
||||
|
||||
| 文件 | 类 | 干什么 |
|
||||
|---|---|---|
|
||||
| `trae-adapter.ts` | `TraeAdapter` | SOLO 协议转换(`config_name`/`function`,非 OpenAI 兼容) |
|
||||
| `llm-adapter.ts` | `CodeArtsAdapter` | 华为云 AK/SK 签名 + `maas_type: benefit` 判定 |
|
||||
| `zcode-adapter.ts` | `ZcodeAdapter` | Anthropic Messages 协议(非 OpenAI) |
|
||||
| `qoder-adapter.ts` | `QoderAdapter` | WASM 加密端点 + 信封剥离 |
|
||||
| `gemini-adapter.ts` | `GeminiAdapter` | `/v1internal:` 私有动词 + 字母序信封 |
|
||||
|
||||
注册方式是 `registerAdapterIdempotent(ctx.llm, [PROVIDER], adapter)`
|
||||
(`llm-adapter.ts:2080`)。
|
||||
|
||||
**但它不重复**:共享层是 `src/openai-compat.ts`(1196 行)——
|
||||
`serializeMessages`(消息序列化)、`consumeOpenAiSse`(SSE 消费)、
|
||||
`httpErrorCode` / `isTransportError`(错误归类)、`userContentParts`(多模态)。
|
||||
12 个 adapter 里有 8 个 `import from './openai-compat'`。
|
||||
|
||||
即:**公共协议逻辑一份,平台差异各自一个类**。
|
||||
|
||||
## 我们的架构:内核是 proxy,插件只注册源
|
||||
|
||||
- 适配器是 Lua 文件(12 个),通过 4 个钩子参与:
|
||||
`transform_request` / `transform_response` / `transform_stream_chunk` /
|
||||
`transform_error`,外加 `build_headers`(动态签名,见下)。
|
||||
- 声明面:`endpoint` / `endpoint_stream` / `ingress` / `headers`。
|
||||
- 插件通过 `__llmsproxy_kernel.call("add_source", {source={...}})` 把平台
|
||||
注册为上游源,`source.adapter` 引用适配器名。
|
||||
|
||||
## 关键发现:机制早就有,但 channels 插件从没用过
|
||||
|
||||
内核**完整实现了**插件自装适配器:
|
||||
|
||||
```
|
||||
internal/lua/plugin_adapter_bridge.go
|
||||
func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error)
|
||||
registerAdapterBridge → Lua 全局表 __llmsproxy_adapters.install / .update / .remove
|
||||
```
|
||||
|
||||
- 有归属校验(`adapterOwnership`:只能改自己装的)
|
||||
- 有冲突仲裁(影子内置/他人所有 → 挂起等运维裁决,不是硬拒)
|
||||
- 有测试证明可用:`TestPluginInstallsAdapterFromLua`(PASS)
|
||||
|
||||
**但 `grep -c '__llmsproxy_adapters' internal/lua/plugins/channels.lua` = 0。**
|
||||
channels 插件一个适配器都没装过 —— 它只会注册 `adapter = "openai"` 的源。
|
||||
这就是"为什么我们不正常"的直接原因:**平台协议差异被当成适配器名差异**。
|
||||
|
||||
## 我们还缺的一层:共享协议模块
|
||||
|
||||
参考有 `openai-compat.ts`;我们没有对应物。`openai.lua` 自己写明
|
||||
「Lua adapters have no shared prelude」,`safe_tool_id` 在 `openai.lua` 与
|
||||
`anthropic.lua` 里**各复制了一份**(各 6 处引用)。
|
||||
|
||||
所以正确做法是两层:
|
||||
1. 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类),
|
||||
或内核在加载时注入 —— 消掉复制粘贴
|
||||
2. channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、
|
||||
codearts AK/SK 签名)**装自己的适配器**,而不是硬塞给 `openai`
|
||||
|
||||
### 所需的适配器能力(crypto + 出站 + 流式)
|
||||
|
||||
已核实内核提供:
|
||||
- `sha256_hex` / `hmac_sha256_hex`(`vm.go` setupGlobals)
|
||||
- `build_headers(meta)` 收 `{url, method, body, api_key, timestamp,
|
||||
client_session, source{name,meta}}` —— CodeArts 的 AK/SK 签名用它
|
||||
(与参考 `signRequestHuawei` 同构,但 AK/SK 要从 `api_key` 里解出)
|
||||
- `transform_stream_chunk` 逐 chunk 调用(`provider.go:1295`)
|
||||
- 插件有完整 OS 能力(`io.popen`/`os.execute`)→ 可用 openssl 做 EC 签名
|
||||
|
||||
### 未验证的风险
|
||||
|
||||
- 适配器 VM 能否访问 `__llmsproxy_secrets`(AK/SK 要解出 SK 才能签名)
|
||||
- 流式转换能否跨 chunk 保持状态(trae 的 SSE 需要跨帧累积 tool_calls)
|
||||
|
||||
这两条是下一步落地前必须先探的。
|
||||
|
||||
---
|
||||
|
||||
# 插件自装适配器:机制与判据(2026-10-13)
|
||||
|
||||
## 机制(内核早已具备,本轮才开始使用)
|
||||
|
||||
```
|
||||
internal/lua/plugin_adapter_bridge.go
|
||||
InstallAdapter(plugin, name, code) # 含归属校验、冲突仲裁(影子内置 → 挂起等裁决)
|
||||
registerAdapterBridge → Lua 全局 __llmsproxy_adapters.install / .update / .remove
|
||||
```
|
||||
注册点在 `bootPluginState`,**先于** `DoString(code)` —— 所以插件在脚本执行期
|
||||
就能装适配器。适配器装在**适配器 VM**(与插件 VM 分离,各自独立)。
|
||||
|
||||
## 三条能力(实测,不是推断)
|
||||
|
||||
`internal/lua/plugin_adapter_capability_test.go` 锁死:
|
||||
|
||||
| 能力 | 结论 | 为什么关键 |
|
||||
|---|---|---|
|
||||
| 拿凭据 | 适配器**能**经 `build_headers` 的 `meta.api_key` 拿到(`url`/`method`/`body` 也在) | AK/SK 签名的前提;同时**验不到** `__llmsproxy_secrets`(只注入插件 VM,安全边界完好) |
|
||||
| 流式状态 | `transform_stream_chunk` **跨 chunk 保持同一 Lua 状态**(表地址恒定) | SOLO 跨帧累积 tool_calls、gemini 跨帧剥信封的前提 |
|
||||
| 加密原语 | 适配器可用内核 `sha256_hex` / `hmac_sha256_hex`(用标准向量校验) | 签名不需要外部进程 |
|
||||
|
||||
## 踩过的坑(都是"静默失败"类,值得记)
|
||||
|
||||
1. **`function` 是 Lua 关键字** —— 写 `body.function = x` 是**语法错误**
|
||||
(`'<name>' expected near 'function'`),必须 `body["function"]`。
|
||||
插件脚本本身能编译通过,**只在适配器 `load()` 时才暴露**。
|
||||
2. **Lua 长字符串里不能有字面 NUL 字节** —— 报
|
||||
`unfinished long string near '<eof>'`,且**报在文件末尾**,完全指不到现场。
|
||||
3. **本 Lua 构建不支持按位异或 `~`**(`unexpected symbol near '~'`)。
|
||||
4. **装载失败只回"源码无法编译"、不给行号** —— 三个自装适配器现统一加
|
||||
**本地预编译护栏**(先 `load` 一次拿行号)。上面三个坑都是靠它定位的。
|
||||
5. **抽公共代码要抽最全的实现**。前导重构时我先抽了 `transform_error` 的
|
||||
最小版(只认 `{error:{message}}`),打挂 6 个既有测试 —— 最小版丢掉了
|
||||
HTML 错误页、裸文本、扁平 `{code,message}`、FastAPI `{detail}`。
|
||||
抽最小实现是**静默降级**:不报错,只是少认几种错误体。
|
||||
6. **判据必须能看见真实函数的返回值**。我一度用 `kernelTestCore` 裸加载插件
|
||||
(没授 `add_source` 权限)测源注册,源一直不出;直到把内核原始返回
|
||||
(`kernel action add_source is not granted to plugin channels`)带出来才
|
||||
定位。**黑盒断言只在失败时告诉你"不对",不告诉你"为什么"**。
|
||||
|
||||
## 判据清单
|
||||
|
||||
| 文件 | 锁死什么 |
|
||||
|---|---|
|
||||
| `internal/lua/plugin_adapter_capability_test.go` | 三条能力(凭据/流式状态/加密原语) |
|
||||
| `internal/lua/adapter_prelude_test.go` | 前导注入 + 语义值正确 + 旧适配器行为不变 |
|
||||
| `internal/lua/adapter_prelude_equivalence_test.go` | 重构前后逐字节等价(含反向断言) |
|
||||
| `internal/lua/channels_trae_solo_test.go` | SOLO 7 条规则 + 三处反向断言 |
|
||||
| `internal/lua/channels_gemini_adapter_test.go` | 身份五头逐值 + 不得发 google key/Accept + 字母序 + 响应剥信封 |
|
||||
| `internal/core/channels_codearts_adapter_test.go` | 装机/归属 + **Go stdlib 独立复算 HMAC 验签** |
|
||||
| `internal/core/channels_private_adapter_wiring_test.go` | 三平台的**源真的引用了**自装适配器 |
|
||||
|
||||
---
|
||||
|
||||
# 生产实测:不可用的真相(2026-10-14)
|
||||
|
||||
## 症状与根因
|
||||
|
||||
用户报"完全不可用"。实测(`/api/plugins/http/channels/account/list` 逐平台):
|
||||
|
||||
| 平台 | 账号数 | **有凭据的** |
|
||||
|---|---|---|
|
||||
| buddy | 2 | **1** |
|
||||
| trae | 3 | 0 |
|
||||
| minimax | 1 | 0 |
|
||||
| lobsterai | 1 | 0 |
|
||||
| codearts | 5 | 0 |
|
||||
| 其余 7 平台 | 0 | 0 |
|
||||
|
||||
**13 个账号里只有 1 个真有凭据**,其余全是**占位壳子**(`has_credential: false`)。
|
||||
占位账号不是"已登录",它只是为「先建账号行、登录后填凭据」这个两步流程预留的
|
||||
行。此前的判断只看「12 平台 0 个 manual」,那是**错的判据** —— manual 是
|
||||
flow 类型,不是"登录成功"。
|
||||
|
||||
## 排除项(都实测过,不是猜)
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| 服务状态 | `active`,`NRestarts=0` |
|
||||
| 部署一致性 | 4 个插件 sha256 与仓库**全部一致**;关键标记(installCodeartsAdapter / installTraeAdapter / installGeminiAdapter / sorted_json / sign_parts)全部在位 |
|
||||
| **推理链路** | `POST /v1/chat/completions` 实测返回 `"OK"`(走 buddy 源、deepseek-v4.1-flash)—— **网关本身完全可用** |
|
||||
| 登录机制 | minimax 真实返回 `user_code=T49S-RC7G` + 验证地址;trae 起了服务端浏览器 + 回环回调 |
|
||||
| 设备码轮询 | 5 次全部 `pending`(未授权时**不误判**为失败或成功 —— 这是正确行为) |
|
||||
|
||||
## 结论:不是代码坏了,是**授权没完成**
|
||||
|
||||
剩下的 12 个平台都需要**人工动作**才能走完登录:
|
||||
|
||||
- **device-poll**(minimax / cline / qoder):把 `user_code` 拿到
|
||||
`account.minimax.cn/oauth-authorize` 之类的**官方页面**输入
|
||||
- **callback**(trae / gemini / lobsterai):在 **VNC 窗口**里点授权
|
||||
(回环回调只能由本机解析,远程浏览器永远接不住)
|
||||
- **qr-poll / wechat-poll**(raccoon / loomy):扫码
|
||||
- **init-poll**(zcode):授权页 + 可能的验证码
|
||||
|
||||
这些**按设计就必须人工**,assistant 无法独立端到端完成。
|
||||
|
||||
## B 类:实测结论
|
||||
|
||||
| 项 | 行数 | 结论 |
|
||||
|---|---|---|
|
||||
| qoder-wasm + envelope | 706 + 226 | **二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。没有 `qoder-auth-wasm.wasm` 无法实现;拿到才有意义 |
|
||||
| zcode-captcha + carrier | 2119 + 93 + 280 + 206 | 阿里云滑块。且**登录本身不需要它** —— captcha 只用于**签到/领取**环节(`zcode-auth.ts:22-23`:claim 时每个 plan 单独 mint captcha)。登录闭环不受阻,受阻的是自动领取 |
|
||||
| captcha-pool | 280 | 同上,服务于领取环节的验证码供给 |
|
||||
|
||||
**修正判断**:B 类不是"登录闭环的拦路虎"。zcode 的登录(`/api/v1/oauth/cli/init`
|
||||
→ poll)不需要验证码;captcha 只在**自动领取**时出现。所以 B 类的真实价值是
|
||||
"让领取全自动",而不是"让平台可用"。
|
||||
|
||||
---
|
||||
|
||||
# 真实数据事故:测试覆盖了生产账号池(2026-10-14)
|
||||
|
||||
## 症状
|
||||
|
||||
用户报「完全不可用」。逐平台 `account/list` 后发现:**13 个账号里只有 1 个有凭据**,
|
||||
其余是占位壳子;再查发现**账号池被清空了**(`pool` 全为空 `{}`),
|
||||
而保险库 `.secrets/channels.json` 里的凭据**还在**。
|
||||
症状正是「账号凭空消失」。
|
||||
|
||||
## 根因(实测输出,非推断)
|
||||
|
||||
channels 插件用 `storeDir()` 决定存储位置,它有多级兜底:
|
||||
|
||||
```
|
||||
env LLMSPROXY_PLUGIN_DIR → settings.store_dir → 读 /etc/llmsproxy/config.yaml 的 plugin_dir → 硬编码
|
||||
```
|
||||
|
||||
测试台没设 `LLMSPROXY_PLUGIN_DIR`,**后两级直接命中生产**:
|
||||
|
||||
```
|
||||
测试插件目录 = /tmp/TestXXX/002/plugins
|
||||
storeDir 结果 = /etc/llmsproxy/plugins ← 探针实测输出
|
||||
```
|
||||
|
||||
于是测试加载真 `channels.lua` 时**读的是生产存储、写的也是生产存储**。
|
||||
一次全量 `go test ./internal/lua` 就把
|
||||
`.channels-store.json` 从 1730 覆盖成 1986 字节、账号池清空(inode 都换了)。
|
||||
|
||||
只有 `internal/lua` 复现,`internal/core` 不复现 —— 因为 core 的测试台
|
||||
(`kernelTestCore`)设了 `LLMSPROXY_PLUGIN_DIR`。**这是隔离做得不一致**的代价。
|
||||
|
||||
同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。
|
||||
|
||||
## 为什么 `DisableStatePersistence` 不够
|
||||
|
||||
第一版修复给 `runtimePlugins` 关了持久化,**仍然复现**:插件声明了 `schedule`
|
||||
时内核会自动起调度器(`plugins.go: p.Runtime = ps.startRuntime(...)`),
|
||||
真 channels 的 `refresh_models` 会在 5s 后(first_due_now)跑一轮并
|
||||
`logEvent → markDirty`。那是**后台 goroutine,比 `t.Setenv` 的生命周期长** ——
|
||||
测试结束后它仍可能落盘,此时环境变量已还原。
|
||||
|
||||
## 修法(两层,第二层才是根治)
|
||||
|
||||
1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜):
|
||||
新增 `__llmsproxy_plugin_dir` 全局(`registerPluginDirBridge`),
|
||||
`storeDir()` 优先用它。**即使没有环境变量也不会落到生产路径** ——
|
||||
实测:撤掉 env 后 `storeDir` 仍落在临时目录。
|
||||
2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()`。
|
||||
|
||||
## 判据 + 变异验证
|
||||
|
||||
`internal/lua/store_dir_isolation_test.go`:
|
||||
`storeDir` 绝不能是 `/etc/llmsproxy` 前缀、必须落在临时目录、env 必须真的被设。
|
||||
**变异验证**:把隔离设成生产路径 → 判据变红并打印「storeDir 指向了**生产**目录」;
|
||||
恢复 → 变绿。
|
||||
|
||||
全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。
|
||||
|
||||
## ⚠️ 修复过程中我自己踩的坑
|
||||
|
||||
恢复账号时我把**保险库文件里的密文**当成明文,喂给 `account/add` 的
|
||||
`credential` —— 保险库 `loadSecretsLocked` **会在读取时解密**,于是那份凭据被
|
||||
**二次加密**(1971 → 2965 字符),该账号的推理开始返回空
|
||||
(`codebuddy_*` 源失效,而非池路径 `channels:buddy` 正常)。
|
||||
|
||||
教训:**凭据只能由登录/续期路径写入,绝不要手工把密文塞回 API**。
|
||||
要复用既有凭据,应走「让插件自己读保险库」的路径,或者重新登录一次。
|
||||
|
||||
---
|
||||
|
||||
# 逐模块对照的当前状态(2026-10-14 更新)
|
||||
|
||||
穷尽对照参考仓 **143 个 .ts 模块 / 77,720 行**。约定:
|
||||
✅ 已对齐 · ⚠️ 部分/简化 · ❌ 未实现 · ⛔ 明确不做
|
||||
|
||||
## 本轮(第 2 批)新补的
|
||||
|
||||
| 模块 | 参考规模 | 补了什么 / 为什么 |
|
||||
|---|---|---|
|
||||
| `dead-model-store` | 708 | 上游下架的模型仍显示且标"免费",一点就 404。**判据只用阳性 404 证据,绝不拿远端目录反推**(buddy 那类平台目录残缺,反推会删可用模型) |
|
||||
| `remote-catalog-gate` | 114 | 参考点名"不能省":目录拉不到会放大成「一次请求 × 每个模型」,首屏卡死 |
|
||||
| `buddy-balance-rank` + `loomy-balance-rank` + 两个 selector | ~1000 | **余额分档选号**:优先用「15 天内到期」的号,否则积分白白作废;锁定时只剩永久的号等同不可用 |
|
||||
| `account-probe` | 568 | `account_test` **此前一个网络请求都不发**(恒返回 ok:true/ms:1)—— 假阳性 |
|
||||
| `model-gate` | 218 | 源改**串行 + 按模型节流**(并发发起必然浪费一个配额;串行不保证间隔) |
|
||||
| `ttl-cache` / `serial-queue` | 291 | 抽成通用件,消掉三处各自手写的缓存/冷却逻辑 |
|
||||
| `opencode-capability` | 423 | isFree 改由 models.dev 的 `cost` 权威决定(不再是硬编码快照)。**受内核 4 MiB 上限约束不可用**,已如实降级为可选增强 |
|
||||
|
||||
另修 3 个**静默失败**:`account_test` 不发请求、业务级错误(HTTP 200 + body 带
|
||||
error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了)。
|
||||
|
||||
## 仍未实现(按影响面排序)
|
||||
|
||||
| 模块 | 行数 | 为什么不做的理由 / 阻塞 |
|
||||
|---|---|---|
|
||||
| `zcode-captcha` + 6 个 captcha-* | ~3345 | 阿里云滑块。**登录不需要它** —— captcha 只在签到/领取环节(`zcode-auth.ts:22-23`)。真实价值是"让领取全自动"而非"让平台可用" |
|
||||
| `qoder-wasm` + `qoder-envelope` | 932 | **wasm 二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。拿不到就无法实现 |
|
||||
| `zcode-identity` / `transport` / `diagnostics` | 1104 | 设备身份 + 传输层 + 错误诊断。与 captcha 链路耦合 |
|
||||
| `account-pool` / `channel-pack-store` | 1912 | 我们已自建(形态不同,非缺口) |
|
||||
| `opencode-proxy` / `opencode-rpc` | 530 | 多出口代理(NAT 后多机共享出口时按 IP 限流才需要) |
|
||||
| `message-shape` | 146 | **不适用**:那是 DSH 宿主的消息形状迁移(0.1.5→0.1.7)。我们的适配器直接透传 OpenAI 形状,无此问题 |
|
||||
| `loop-recovery` | 346 | **前提不存在**:它做的是"循环守卫中止后自动续跑",而我们的内核**没有循环守卫** |
|
||||
| `reasoning-ladder` | 182 | 需要内核支持 `efforts` 字段(`Model` 结构只有 id/priority/kind/meta)。属内核改动,按纪律不做 |
|
||||
| `badge-*` / `image-budget` / `ttl-cache`(已补) | ~500 | 徽标订阅、图片预算 —— 宿主 UI 特性 |
|
||||
| `raccoon-login-page` / `raccoon-qr` | 1124 | 登录页与二维码的完整实现(我们走简化版) |
|
||||
| `zcode-carrier-page` | 93 | 内部载体页(依赖 DSH Desktop 的 webview guest,我们没有那个壳) |
|
||||
| `cline-*` 4 个 / `gemini-project` / `gemini-sigstore` / `loomy-sign`(部分) | ~900 | 平台细节与签名持久化 |
|
||||
|
||||
## 一句话结论
|
||||
|
||||
**平台登录/凭据/源注册这条主线已打通且逐平台对齐**(12 平台零 manual、三平台
|
||||
自装适配器、SOLO/信封/AK-SK 签名逐字节验证)。
|
||||
**未实现的部分集中在两类**:(1) 需要**真实浏览器/验证码**的领取全自动
|
||||
(captcha 系 ~3300 行);(2) **二进制缺失**(qoder wasm)。
|
||||
其余是宿主 UI 特性或前提不存在的模块。
|
||||
|
||||
---
|
||||
|
||||
# 第三批(2026-10-14 晚)—— 逐模块对照的**新发现**
|
||||
|
||||
## ★ 本轮新补的三个真缺陷
|
||||
|
||||
### ① 自动领取每天静默漏领(`credits.ts` / `auto-checkin.ts`)
|
||||
活动刷新时刻**各平台不同**(Qoder 每日 10:00 UTC+8),而我们只在 08:00 跑一次
|
||||
⇒ 上午看到昨天那条 `CLAIMED`、报 `already-claimed` 并记账 ⇒ 当天再不去领。
|
||||
**修法照参考**:不配"时刻表"(必然漂移),让**产出方声明 `covers_today`**,
|
||||
记账方只数"有几条能证明今天已处理"。两层都要:频率提高 + `first_due_now`。
|
||||
|
||||
### ② 凭据续期此前是假的(`refresh.ts` / `expiry-sync.ts`)
|
||||
`account_refresh` **一个换 token 的请求都不发**却恒返回 `ok:true`
|
||||
⇒ `refresh_creds` 打「已续期 N 个」而什么都没续 —— **日志在说谎**。
|
||||
这是本轮第三次遇到同形态(前两次:`account_test` 恒 ok、业务级错误被当成功)。
|
||||
|
||||
### ③ `isFree` 与声明自相矛盾(`cline-models.ts`)
|
||||
cline 的免费模型声明在 `models` 里 ⇒ 被标成收费。
|
||||
★ 关键认识:**两个参考用的是不同机制** —— cline 用远端 `free[]`,
|
||||
opencode 用 models.dev 的 `cost`。混成一条 OR 链会与既有判据正面冲突。
|
||||
⇒ 加 `free_source` 由**平台声明**权威来源。
|
||||
|
||||
## 插件自装适配器(第 3、4 个)
|
||||
| 适配器 | 解决的问题 |
|
||||
|---|---|
|
||||
| `channels-qoder` | 错误信封**双层编码**;`10605`(排队) 与 `110`(额度耗尽) 语义**相反**,必须分开说(参考实测:110 原被归成通用服务端错误 → 白重试 5 次 ≈15.5 秒) |
|
||||
| `channels-cline` | 429 **无 retry-after 头**、等待时长写在英文句子里(`Try again in 19h 39m`)。解析不出 ⇒ 内核按 30 秒短冷却 ⇒ **每 30 秒猛敲上游**(比参考的「永远 60 分钟」更糟) |
|
||||
|
||||
现共 4 个:`channels-trae` / `channels-gemini` / `channels-qoder` / `channels-cline`。
|
||||
|
||||
## 对照后判定「不适用」的(附理由,不是"没做")
|
||||
|
||||
| 模块 | 为什么不需要 |
|
||||
|---|---|
|
||||
| `account-pool` 并发写竞态 | 我们的落盘是**同步** tmp+rename(Lua 无异步),每次写序列化**当前** state。参考的缺陷是"异步后端 + 共享可变快照"的产物。**已加判据钉住**(已删账号不复活、新增不丢失、内存/磁盘不分叉) |
|
||||
| `gemini-messages` SSE 末帧丢失 | 我们的 `bufio.Scanner` 对无尾换行的最后一行**照样产出**(已实测自证)。参考那是在 `while(!done)` 里按行处理所致 |
|
||||
| `cline-rate-limit` 的 30 分钟策略冷却 | 我们**每账号一个源**(`channels-<plat>-<acct>`)⇒ 某账号被拦只冷却它自己,下个请求自然换源。参考是"一个池里换号",才需要长冷却挡住同一轮里的连续重撞 |
|
||||
| `message-shape` | DSH 宿主的消息形状迁移;我们直接透传 OpenAI 形状 |
|
||||
| `loop-recovery` | 前提不存在(我们的内核没有循环守卫) |
|
||||
| `reasoning-ladder` | 需要内核支持 `efforts` 字段(属内核改动,按纪律不做) |
|
||||
| `retry-after.ts` | 参考那份实现**自己就是死代码**(grep 无消费者);我们的固定 30s 冷却有明确理由(429 是"太快"不是"坏了",走指数退避会让额度充沛的源被锁 30 分钟) |
|
||||
| `image-budget` | 实际缩放委托给宿主的附件服务,我们无对应物 |
|
||||
|
||||
## 运行环境的一条教训(不是代码缺陷)
|
||||
全量 `go test` 曾出现 `TestConcurrentPluginAdapterUpdates` **挂 59 分钟**、
|
||||
看着像死锁。单独跑 **0.058s 通过** ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建
|
||||
缓存占满 100%**。教训:构建/测试挂住时先 `df -h /tmp`(`/` 空闲不代表 `/tmp` 够)。
|
||||
117
docs/design-plugin-kernel-api.md
Normal file
117
docs/design-plugin-kernel-api.md
Normal file
@ -0,0 +1,117 @@
|
||||
# 插件可调用内核动作(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 生效、危险动作需显式授权
|
||||
158
docs/git-workflow-en.md
Normal file
158
docs/git-workflow-en.md
Normal file
@ -0,0 +1,158 @@
|
||||
# Git Branching Workflow
|
||||
|
||||
ModelRouter uses a **GitHub Flow + release-branch** model: `main` is the only
|
||||
long-lived branch and is always deployable; new work branches off it as a
|
||||
feature branch; each version gets its own release branch with a tag; hotfixes
|
||||
land on the release branch and MUST flow back to `main`.
|
||||
|
||||
Goal: make "patching an already-released version during its support window" a
|
||||
first-class operation, while `main` always contains every fix and stays
|
||||
deployable.
|
||||
|
||||
## Branch roles
|
||||
|
||||
| Branch | Name | Lifetime | Purpose |
|
||||
|---|---|---|---|
|
||||
| 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. |
|
||||
|
||||
## Change flow (important)
|
||||
|
||||
```
|
||||
feature/foo ───────┐
|
||||
▼
|
||||
main ──────────────── next version ──────►
|
||||
│ │
|
||||
│ cut │ cut
|
||||
▼ ▼
|
||||
release/v1.6.x release/v1.7.x
|
||||
│ │
|
||||
tag: v1.6.0 tag: v1.7.6
|
||||
│ │
|
||||
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`.
|
||||
Unfinished work lives on a feature branch.
|
||||
2. **Features start from main and merge back**: `git checkout -b feature/xxx main`,
|
||||
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
|
||||
```
|
||||
Build installers and upload the GitCode Release from this tag so the
|
||||
published state is exactly reproducible.
|
||||
4. **Only version-specific hotfixes go into a release branch during its
|
||||
window**: new features always go to `main` for the next version, never into
|
||||
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 commit -m "fix: ..."
|
||||
git checkout main
|
||||
git cherry-pick <hotfix-commit> # and into main
|
||||
```
|
||||
Most important rule: **main only moves forward and never loses fixes**. If a
|
||||
hotfix never reaches main, the next release ships with the old bug.
|
||||
|
||||
### End of a version lifecycle
|
||||
|
||||
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
|
||||
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
|
||||
directly usable history.
|
||||
- **Release branches are not merged back wholesale**: hotfixes were already
|
||||
cherry-picked; a whole-branch merge only creates conflicts with no benefit.
|
||||
- **No "quick edit" directly on main**, even single-person: at minimum use
|
||||
`feature/xxx → merge main` so history keeps its why-boundaries.
|
||||
|
||||
## Single vs multi person
|
||||
|
||||
- **Single** (this repo today): skip PRs — `git checkout -b feature/xxx` →
|
||||
finish → merge back to main. Release branch + tag flow unchanged.
|
||||
- **Multi / open source**: feature branches go through PR + Code Review,
|
||||
maintainers merge; resolve conflicts on the feature branch, never on main.
|
||||
|
||||
## Commit message conventions
|
||||
|
||||
- `feat(...)`: new feature
|
||||
- `fix(...)`: bug fix
|
||||
- `chore(...)`: build / version / deps / non-code changes
|
||||
- `docs(...)`: documentation
|
||||
- `refactor(...)`: refactoring
|
||||
- Scope in parens, e.g. `fix(adapters)`, `feat(webui)`, `chore(version)`
|
||||
- One-line summary; expand with problem/root-cause/fix/verification when needed
|
||||
|
||||
## Release checklist (companion)
|
||||
|
||||
```bash
|
||||
# 1. main is ready
|
||||
git checkout main && git pull
|
||||
|
||||
# 2. cut release branch + tag
|
||||
git checkout -b release/vX.Y.Z main
|
||||
git tag -a vX.Y.Z -m "ModelRouter vX.Y.Z"
|
||||
|
||||
# 3. push branch + tag
|
||||
git push origin release/vX.Y.Z
|
||||
git push origin vX.Y.Z
|
||||
|
||||
# 4. build installers (core + GUI platforms)
|
||||
make core-dist VERSION=X.Y.Z
|
||||
|
||||
# 5. create GitCode Release + upload
|
||||
# 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.
|
||||
```
|
||||
|
||||
## Why this model (background)
|
||||
|
||||
Before 2026-08 everything went straight to `main` plus a tag, which caused two
|
||||
pain points:
|
||||
1. After 1.4.2 shipped, a scheduler-scoring fix had to be added to the same
|
||||
version — the only option was deleting the remote tag to clear the release
|
||||
and re-uploading all installers. The published state was detached from code
|
||||
history; "what exactly shipped" could not be traced.
|
||||
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".
|
||||
|
||||
> 中文版见 [docs/git-workflow.md](git-workflow.md)。
|
||||
142
docs/git-workflow.md
Normal file
142
docs/git-workflow.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Git 分支工作流(Branching Workflow)
|
||||
|
||||
ModelRouter 采用 **GitHub Flow + 发布分支** 模型:`main` 是唯一长命分支且永远可部署,
|
||||
新工作从它开出特性分支;每个版本从 `main` 分出独立的发布分支并打 tag;hotfix 提交到
|
||||
发布分支且必须回流 `main`。
|
||||
|
||||
目的:让「已发版的版本在生命周期内可被单独修补」成为一等操作,同时 `main` 始终包含
|
||||
全部修复、永远可部署。
|
||||
|
||||
## 分支角色
|
||||
|
||||
| 分支 | 命名 | 生命周期 | 用途 |
|
||||
|---|---|---|---|
|
||||
| 主分支 | `main` | 永久 | 唯一长命分支。永远可部署。积攒下一个版本的功能。 |
|
||||
| 特性分支 | `feature/<描述>` | 短命(开发→合并即删) | 新特性 / 一般 bug 修复。从 `main` 开出,完成后合回 `main`。 |
|
||||
| 发布分支 | `release/vX.Y.x` | 一个版本周期 | 从 `main` 分出,打 tag 发布。该版本生命周期内的 hotfix 都提交在此分支。 |
|
||||
|
||||
## 变更流向(重要)
|
||||
|
||||
```
|
||||
feature/foo ───────┐
|
||||
▼
|
||||
main ──────────────── 下一个版本 ──────►
|
||||
│ │
|
||||
│ 切出 │ 切出
|
||||
▼ ▼
|
||||
release/v1.6.x release/v1.7.x
|
||||
│ │
|
||||
tag: v1.6.0 tag: v1.7.6
|
||||
│ │
|
||||
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 上留半成品。任何未完成的工作必须在特性分支上。
|
||||
2. **特性从 main 开,完成后合回 main**:`git checkout -b feature/xxx 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
|
||||
```
|
||||
构建安装包、上传 GitCode Release 都基于这个 tag,保证可精确回溯发布态。
|
||||
4. **版本生命周期内只收该版本的 hotfix**:新特性一律并入 `main` 等下一个版本,
|
||||
绝不塞进已发布的 release 分支(除非主动选择在该版本内发次要版)。
|
||||
5. **hotfix 必须回流 main**:
|
||||
```bash
|
||||
git checkout release/v1.7.x # 在发布分支提交修复
|
||||
git commit -m "fix: ..."
|
||||
git checkout main
|
||||
git cherry-pick <hotfix-commit> # 回主分支
|
||||
```
|
||||
这是本模型最重要的一条:**main 只前进、不丢修复**。如果 hotfix 不回 main,
|
||||
下一个版本就会带着旧 bug 发布。
|
||||
|
||||
### 版本生命周期结束
|
||||
|
||||
下一个版本发布时,上一个 release 分支退役:
|
||||
|
||||
- **默认:直接删除远端 release 分支**(`git push origin :release/v1.7.x`)。
|
||||
因为 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` 都得到直接可用的历史。
|
||||
- **发布分支不整体 merge 回 main**:hotfix 已逐个 cherry-pick,整体合并只会制造冲突且无收益。
|
||||
- **不在 main 直接写"临时改一下"**:即使是单人项目,也至少走
|
||||
`feature/xxx → merge main` 的形式,让历史保留"为什么改"的边界。
|
||||
|
||||
## 单人 vs 多人
|
||||
|
||||
- **单人**(本项目当前):特性分支可省去 PR,`git checkout -b feature/xxx` →
|
||||
完成 → 合并回 main。发布分支与 tag 流程完全一致。
|
||||
- **多人 / 开源协作**:特性分支走 PR(pull request)+ Code Review,
|
||||
由维护者 merge;冲突在特性分支上解决,不在 main 上解决。
|
||||
|
||||
## 提交信息规范(与分支配套)
|
||||
|
||||
- `feat(...)`: 新功能
|
||||
- `fix(...)`: 修复
|
||||
- `chore(...)`: 构建 / 版本号 / 依赖 / 文档无关改动
|
||||
- `docs(...)`: 文档
|
||||
- `refactor(...)`: 重构
|
||||
- 括号内为影响域,如 `fix(adapters)`、`feat(webui)`、`chore(version)`
|
||||
- 一句话总结,必要时正文展开「问题 / 根因 / 修复 / 验证」
|
||||
|
||||
## 发布操作速查(配套)
|
||||
|
||||
每次发布严格按此顺序:
|
||||
|
||||
```bash
|
||||
# 1. 确认 main 就绪
|
||||
git checkout main && git pull
|
||||
|
||||
# 2. 切发布分支并打 tag
|
||||
git checkout -b release/vX.Y.Z main
|
||||
git tag -a vX.Y.Z -m "ModelRouter vX.Y.Z"
|
||||
|
||||
# 3. 推送分支 + tag
|
||||
git push origin release/vX.Y.Z
|
||||
git push origin vX.Y.Z
|
||||
|
||||
# 4. 构建安装包(核心包 + GUI 各平台)
|
||||
make core-dist VERSION=X.Y.Z
|
||||
|
||||
# 5. 创建 GitCode Release + 上传安装包
|
||||
# 见 https://gitcode.com/JianFeeeee/ModelRouter/releases
|
||||
# 上传注意:必须 PUT --http1.1,否则 OBS 回调失败文件不进列表;
|
||||
# 替换旧版附件 = 删 tag(release 随 tag 一起消失)后重建。
|
||||
```
|
||||
|
||||
## 为什么是这套(背景)
|
||||
|
||||
2026-08 之前本项目直接在所有改动提交到 `main` + 打 tag,出现两个痛点:
|
||||
1. 1.4.2 已发版后又追了一个调度打分修复,只能删远端 tag 清空 release 再重传安装包
|
||||
—— 版本发布态与代码历史脱节,无法精确回溯"当时发的是什么"。
|
||||
2. 没有特性分支,无法并行开发互不干扰的两件事。
|
||||
|
||||
引入发布分支后:版本发布态 = `release/vX.Y.Z` 分支 + `vX.Y.Z` tag,可精确回溯;
|
||||
hotfix 有明确落点;main 保持"最新 + 全部修复 + 可部署"。
|
||||
|
||||
> 英文版见 [docs/git-workflow-en.md](git-workflow-en.md)。
|
||||
@ -23,7 +23,7 @@
|
||||
5. [内置辅助函数](#5-内置辅助函数)
|
||||
6. [meta 约定](#6-meta-约定)
|
||||
7. [一个完整的最小适配器](#7-一个完整的最小适配器)
|
||||
8. [多模态与 disable_thinking](#8-多模态与-disable_thinking)
|
||||
8. [多模态与 thinking 预算](#8-多模态与-thinking-预算)
|
||||
|
||||
---
|
||||
|
||||
@ -73,8 +73,9 @@ end
|
||||
|
||||
约定:
|
||||
- 返回的字符串将被原样 POST 到 `base_url + (source.endpoint or adapter.endpoint)`。
|
||||
- 请求体中包含的 `disable_thinking`、`extra_body` 等网关内部字段,转换时应自行清理
|
||||
(`openai.lua` 即删掉这两个字段)。
|
||||
- 契约 v2 起,内核**不再向适配器泄漏任何私有字段**:请求体里不会出现
|
||||
`disable_thinking` / `extra_body` 这类键。推理开关与预算用中立的 `thinking`
|
||||
字段表达(见下),适配器按目标协议各自映射。
|
||||
|
||||
### `transform_response(raw_body) -> string`
|
||||
|
||||
@ -176,6 +177,81 @@ 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 注入):
|
||||
@ -219,8 +295,13 @@ 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
|
||||
req.disable_thinking = nil
|
||||
req.extra_body = nil
|
||||
-- 契约 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
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
@ -268,11 +349,21 @@ sources:
|
||||
kind: chat
|
||||
```
|
||||
|
||||
## 8. 多模态与 disable_thinking
|
||||
## 8. 多模态与 thinking 预算
|
||||
|
||||
- **多模态**:请求的 `content` 若是数组(`[{type:"text"...},{type:"image_url"...}]`),Go 层
|
||||
原样透传给适配器。协议不支持的源(Anthropic/Gemini/Ollama)需在
|
||||
`transform_request` 内转换;内置 `anthropic.lua`/`gemini.lua`/`ollama.lua` 已实现。
|
||||
- **disable_thinking**:网关将请求体中的 `disable_thinking:true` 透传进
|
||||
`transform_request`。DeepSeek 适配器将其映射为 `extra_body.thinking = { type = "disabled" }`
|
||||
并清理自身字段;其余适配器按各自协议处理(可自行实现或忽略)。
|
||||
- **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`)。
|
||||
|
||||
673
docs/plugins.md
Normal file
673
docs/plugins.md
Normal file
@ -0,0 +1,673 @@
|
||||
# 插件系统(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——可以是一整个
|
||||
新页面,也可以是往现有页面里追加一个组件。
|
||||
|
||||
两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing)
|
||||
└─ LoadDir() 按文件名字典序逐个加载
|
||||
```
|
||||
|
||||
**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `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/llmsproxy/plugins"
|
||||
}
|
||||
```
|
||||
|
||||
`hook_errors` 是**排错入口**:一个坏掉的插件表现为"功能缺失"而不是报错,
|
||||
这个计数让它可见。
|
||||
|
||||
### 5.4 定时任务(`PLUGIN.schedule`)—— 浏览器关着也会跑
|
||||
|
||||
要求插件做“随时间发生”的事(自动签到、token 轮换、额度轮询)时,声明一个
|
||||
调度表:
|
||||
|
||||
```lua
|
||||
plugin.schedule = {
|
||||
{ fn = "refresh_token", every = 3600 }, -- 每小时(秒)
|
||||
{ fn = "daily_checkin", 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 = __llmsproxy_adapters.install(
|
||||
"myauth",
|
||||
'return { name = "myauth", endpoint = "/chat/completions",' ..
|
||||
' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }'
|
||||
)
|
||||
if err then log("error", err) end
|
||||
end
|
||||
```
|
||||
|
||||
| 调用 | 参数 | 返回 |
|
||||
|---|---|---|
|
||||
| `__llmsproxy_adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` |
|
||||
| `__llmsproxy_adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
|
||||
| `__llmsproxy_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,**不要占用**
|
||||
- 插件间状态完全隔离,一个插件改不了另一个的全局
|
||||
|
||||
---
|
||||
|
||||
## 7. 内置计费插件
|
||||
|
||||
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
|
||||
|
||||
### 7.1 计费维度
|
||||
|
||||
| 维度 | 用途 | 优先级 |
|
||||
|---|---|---|
|
||||
| `sources` | 源的固定价(如按请求计费的转发服务) | 中 |
|
||||
| `models` | 模型的 per-token 价 | 高 |
|
||||
| `keys` | 单个网关 key 的覆盖价 | **最高** |
|
||||
|
||||
**token 价**优先级:`keys` > `models` > `default`。
|
||||
**`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 就去补价目。
|
||||
|
||||
注意"未定价"不等于"免费":这些请求的 `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 llmsproxy -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)。
|
||||
286
e2e/adapter_takeover_test.go
Normal file
286
e2e/adapter_takeover_test.go
Normal file
@ -0,0 +1,286 @@
|
||||
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
|
||||
170
e2e/channels_device_e2e_test.go
Normal file
170
e2e/channels_device_e2e_test.go
Normal file
@ -0,0 +1,170 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestChannelsDevicePollE2E 用**真二进制**跑通 channels 插件的 device-poll 登录:
|
||||
//
|
||||
// 发起登录(start_login) → 页面轮询(poll_login) 推进设备码换 token
|
||||
// → 凭据入保险库 → add_source 把平台接成上游源 → /v1/models 里出现其模型
|
||||
//
|
||||
// 这是对参考仓库 dsh-our-free-model「登录→领额度→接入源」形态的端到端对齐:
|
||||
// 行为(HTTP 决策分支、会话推进、最终可路由)在真进程里验证,而不是单元桩。
|
||||
//
|
||||
// 假上游模拟 MiniMax 的两个端点:设备码申请 + token 轮询(脚本化逐次响应)。
|
||||
func TestChannelsDevicePollE2E(t *testing.T) {
|
||||
// ---- 假平台上游(loopback,白名单天然放行)----
|
||||
var tokenHits int32
|
||||
var tokenScript atomic.Value // []scriptStep
|
||||
tokenScript.Store([]scriptStep{})
|
||||
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/oauth2/device/code":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"device_code":"DEV-E2E","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
|
||||
case "/oauth2/token":
|
||||
i := int(atomic.AddInt32(&tokenHits, 1)) - 1
|
||||
script := tokenScript.Load().([]scriptStep)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if i >= len(script) {
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprint(w, `{"error":"unexpected"}`)
|
||||
return
|
||||
}
|
||||
if script[i].code != 0 && script[i].code != 200 {
|
||||
w.WriteHeader(script[i].code)
|
||||
}
|
||||
fmt.Fprint(w, script[i].body)
|
||||
case "/v1/models":
|
||||
// 接入源后内核会探测可达;回一个 OpenAI 形状的模型列表
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"object":"list","data":[{"id":"MiniMax-M3","object":"model"}]}`)
|
||||
case "/v1/chat/completions":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer plat.Close()
|
||||
|
||||
// ---- 真二进制 + 配置(带 plugin_dir,让 bundled channels 落地 + 授权可持久化)----
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
pluginDir := filepath.Join(dir, "plugins") // 不预建:SeedBundled 见空目录才落地内置插件
|
||||
cfg := "listen: " + addr + "\n" +
|
||||
"gateway_keys:\n - sk-e2e-0001\n" +
|
||||
"default_model: none\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + pluginDir + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"sources: []\n"
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := buildBinary(t)
|
||||
g := startGatewayNoModels(t, bin, addr, cfgPath)
|
||||
|
||||
call := func(method, path, body string) (int, map[string]interface{}) {
|
||||
resp, raw := g.do(method, path, body, true)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
return statusOf(resp), m
|
||||
}
|
||||
|
||||
// 1) 授权内核动作(运维批准 add_source 等)+ 把 minimax 端点指到假上游。
|
||||
st, m := call("PUT", "/api/plugins/channels/kernel",
|
||||
`{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`)
|
||||
if st != 200 {
|
||||
t.Fatalf("授权内核动作失败: status=%d body=%v", st, m)
|
||||
}
|
||||
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"minimax","base_url":%q,"device":{"code_url":%q,"token_url":%q}}`,
|
||||
plat.URL+"/v1", plat.URL+"/oauth2/device/code", plat.URL+"/oauth2/token"))
|
||||
if st != 200 {
|
||||
t.Fatalf("set_endpoints 失败: status=%d body=%v", st, m)
|
||||
}
|
||||
|
||||
// 2) 发起 device-poll 登录:拿到会话 + user_code。
|
||||
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"minimax"}`)
|
||||
if st != 200 {
|
||||
t.Fatalf("start_login status=%d body=%v", st, m)
|
||||
}
|
||||
sess, _ := m["session"].(map[string]interface{})
|
||||
if sess == nil || sess["user_code"] != "WXYZ-1234" {
|
||||
t.Fatalf("start_login 未透传 user_code: %v", m)
|
||||
}
|
||||
sid, _ := sess["id"].(string)
|
||||
if sid == "" {
|
||||
t.Fatal("无会话 id")
|
||||
}
|
||||
|
||||
// 3) 第一次轮询:上游 pending → 会话仍 pending。
|
||||
tokenScript.Store([]scriptStep{{code: 400, body: `{"error":"authorization_pending"}`}})
|
||||
atomic.StoreInt32(&tokenHits, 0)
|
||||
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
|
||||
if m["state"] != "pending" {
|
||||
t.Fatalf("轮询1 应 pending, 实得 %v", m)
|
||||
}
|
||||
|
||||
// 4) 第二次轮询:上游发 access_token → 会话 completed,凭据入库 + 接入源。
|
||||
tokenScript.Store([]scriptStep{{code: 200, body: `{"access_token":"MM-E2E-TOKEN","refresh_token":"MM-RF","nickname":"e2e-user"}`}})
|
||||
atomic.StoreInt32(&tokenHits, 0)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
|
||||
if m["state"] == "completed" || m["state"] == "failed" {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
if m["state"] != "completed" {
|
||||
t.Fatalf("轮询2 应 completed, 实得 %v", m)
|
||||
}
|
||||
if m["account"] != "e2e-user" {
|
||||
t.Fatalf("account 应取 nickname, 实得 %v", m)
|
||||
}
|
||||
|
||||
// 5) 接入源的终局证据:/v1/models 里出现 channels-minimax 的模型。
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
var modelsBody string
|
||||
for time.Now().Before(deadline) {
|
||||
resp, raw := g.do("GET", "/v1/models", "", true)
|
||||
modelsBody = raw
|
||||
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "MiniMax-M3") {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(modelsBody, "MiniMax-M3") {
|
||||
t.Fatalf("接入源后 /v1/models 未出现 MiniMax-M3: %s", modelsBody)
|
||||
}
|
||||
}
|
||||
|
||||
type scriptStep struct {
|
||||
code int
|
||||
body string
|
||||
}
|
||||
|
||||
// startGatewayNoModels 与 startGateway 相同,但就绪探针不要求 /v1/models 返回
|
||||
// 非空:本用例启动时没有任何源,/v1/models 是空列表(200 即就绪)。
|
||||
func startGatewayNoModels(t *testing.T, bin, listen, cfgPath string) *gatewayUnderTest {
|
||||
t.Helper()
|
||||
return startGateway(t, bin, listen, cfgPath)
|
||||
}
|
||||
|
||||
var _ = net.Listen
|
||||
var _ = io.Discard
|
||||
209
e2e/channels_flows_e2e_test.go
Normal file
209
e2e/channels_flows_e2e_test.go
Normal file
@ -0,0 +1,209 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestChannelsFlowsE2E 用真二进制补齐另外三条流程的端到端:
|
||||
//
|
||||
// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据)
|
||||
// paste(opencode:直接 complete_login 贴 API key)
|
||||
// device-poll 授权页(qoder:本地造 PKCE,把手动授权页给用户,再凭据完成)
|
||||
//
|
||||
// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。
|
||||
func TestChannelsFlowsE2E(t *testing.T) {
|
||||
var authHits, regHits int32
|
||||
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
// ---- Cline(WorkOS)----
|
||||
case "/user_management/authorize/device":
|
||||
fmt.Fprint(w, `{"device_code":"CD-E2E","user_code":"CLNE-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
|
||||
case "/user_management/authenticate":
|
||||
if atomic.AddInt32(&authHits, 1) == 1 {
|
||||
w.WriteHeader(400)
|
||||
fmt.Fprint(w, `{"error":"authorization_pending"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"access_token":"WO-E2E-AT","refresh_token":"WO-E2E-RT"}`)
|
||||
case "/api/v1/auth/register":
|
||||
atomic.AddInt32(®Hits, 1)
|
||||
fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-E2E-AT","refreshToken":"CLINE-E2E-RT","userInfo":{"email":"dev@example.com"}}}`)
|
||||
// ---- 上游推理面(opencode/qoder/cline 的 base_url 都指到这里)----
|
||||
case "/v1/models":
|
||||
fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qmodel_38max","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`)
|
||||
case "/v1/chat/completions":
|
||||
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer plat.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
cfg := "listen: " + addr + "\n" +
|
||||
"gateway_keys:\n - sk-e2e-0001\n" +
|
||||
"default_model: none\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + pluginDir + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"sources: []\n"
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := buildBinary(t)
|
||||
g := startGatewayNoModels(t, bin, addr, cfgPath)
|
||||
|
||||
call := func(method, path, body string) (int, map[string]interface{}) {
|
||||
resp, raw := g.do(method, path, body, true)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
return statusOf(resp), m
|
||||
}
|
||||
|
||||
st, m := call("PUT", "/api/plugins/channels/kernel",
|
||||
`{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`)
|
||||
if st != 200 {
|
||||
t.Fatalf("授权内核动作失败: status=%d body=%v", st, m)
|
||||
}
|
||||
|
||||
// ---------- ① Cline:WorkOS 设备码两步 ----------
|
||||
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"cline","base_url":%q,"device":{"code_url":%q,"token_url":%q},"register":{"url":%q}}`,
|
||||
plat.URL, plat.URL+"/user_management/authorize/device",
|
||||
plat.URL+"/user_management/authenticate", plat.URL+"/api/v1/auth/register"))
|
||||
if st != 200 {
|
||||
t.Fatalf("set_endpoints(cline) 失败: status=%d body=%v", st, m)
|
||||
}
|
||||
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"cline"}`)
|
||||
if st != 200 {
|
||||
t.Fatalf("cline start_login status=%d body=%v", st, m)
|
||||
}
|
||||
sess, _ := m["session"].(map[string]interface{})
|
||||
if sess == nil || sess["user_code"] != "CLNE-1234" {
|
||||
t.Fatalf("cline start_login 未透传 user_code: %v", m)
|
||||
}
|
||||
sid, _ := sess["id"].(string)
|
||||
|
||||
// 第一拍 pending(authorization_pending),第二拍 completed
|
||||
atomic.StoreInt32(&authHits, 0)
|
||||
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
|
||||
if m["state"] != "pending" {
|
||||
t.Fatalf("cline 轮询1 应 pending, 实得 %v", m)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
|
||||
if m["state"] == "completed" || m["state"] == "failed" {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
if m["state"] != "completed" {
|
||||
t.Fatalf("cline 轮询2 应 completed, 实得 %v", m)
|
||||
}
|
||||
if m["account"] != "dev@example.com" {
|
||||
t.Fatalf("cline account 应取 register userInfo.email, 实得 %v", m)
|
||||
}
|
||||
if atomic.LoadInt32(®Hits) != 1 {
|
||||
t.Fatalf("register 应恰好调用一次, 实得 %d", regHits)
|
||||
}
|
||||
// 凭据必须是 register 的最终 token,不是 WorkOS token
|
||||
var modelsBody string
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, raw := g.do("GET", "/v1/models", "", true)
|
||||
modelsBody = raw
|
||||
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "stealth/space-bunny-alpha") {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(modelsBody, "stealth/space-bunny-alpha") {
|
||||
t.Fatalf("cline 接入后 /v1/models 未出现其模型: %s", modelsBody)
|
||||
}
|
||||
|
||||
// ---------- ② paste(opencode):complete 直接贴 key ----------
|
||||
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"opencode","base_url":%q}`, plat.URL))
|
||||
if st != 200 {
|
||||
t.Fatalf("set_endpoints(opencode) 失败: %v", m)
|
||||
}
|
||||
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"opencode"}`)
|
||||
if st != 200 {
|
||||
t.Fatalf("opencode start_login status=%d", st)
|
||||
}
|
||||
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "paste" {
|
||||
t.Fatalf("opencode 会话 flow 应 paste: %v", m)
|
||||
}
|
||||
st, m = call("POST", "/api/plugins/http/channels/login/complete",
|
||||
`{"platform":"opencode","credential":"OC-E2E-KEY"}`)
|
||||
if st != 200 || m["ok"] != true {
|
||||
t.Fatalf("opencode complete 失败: status=%d body=%v", st, m)
|
||||
}
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, raw := g.do("GET", "/v1/models", "", true)
|
||||
modelsBody = raw
|
||||
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "big-pickle") {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(modelsBody, "big-pickle") {
|
||||
t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody)
|
||||
}
|
||||
|
||||
// ---------- ③ device-poll(qoder):给授权页 + 凭据完成 ----------
|
||||
// qoder 曾走 manual(返回 manual_hint);现已改为真实的 PKCE 授权页流程
|
||||
// (本地造 challenge/verifier,服务端按 nonce 找回待授权会话)。
|
||||
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"qoder","base_url":%q}`, plat.URL))
|
||||
if st != 200 {
|
||||
t.Fatalf("set_endpoints(qoder) 失败: %v", m)
|
||||
}
|
||||
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"qoder"}`)
|
||||
if st != 200 {
|
||||
t.Fatalf("qoder start_login status=%d", st)
|
||||
}
|
||||
// 真流程必须给出授权页 URL(而不是「不支持自动登录」的 manual_hint),
|
||||
// 且会话声明为 device-poll。
|
||||
if hint, _ := m["manual_hint"].(string); hint != "" {
|
||||
t.Fatalf("qoder 已不再走 manual,不应返回 manual_hint: %q", hint)
|
||||
}
|
||||
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "device-poll" {
|
||||
t.Fatalf("qoder 会话 flow 应 device-poll: %v", m)
|
||||
}
|
||||
if authURL, _ := m["url"].(string); !strings.Contains(authURL, "challenge=") {
|
||||
t.Fatalf("qoder 授权页 URL 应带 PKCE challenge,实得 %q", authURL)
|
||||
}
|
||||
st, m = call("POST", "/api/plugins/http/channels/login/complete",
|
||||
`{"platform":"qoder","credential":"QD-E2E-KEY"}`)
|
||||
if st != 200 || m["ok"] != true {
|
||||
t.Fatalf("qoder complete 失败: status=%d body=%v", st, m)
|
||||
}
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, raw := g.do("GET", "/v1/models", "", true)
|
||||
modelsBody = raw
|
||||
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qmodel_38max") {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(modelsBody, "qmodel_38max") {
|
||||
t.Fatalf("qoder 接入后 /v1/models 未出现 qmodel_38max: %s", modelsBody)
|
||||
}
|
||||
}
|
||||
261
e2e/conflict_arbitration_e2e_test.go
Normal file
261
e2e/conflict_arbitration_e2e_test.go
Normal file
@ -0,0 +1,261 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
140
e2e/e2e_test.go
140
e2e/e2e_test.go
@ -327,3 +327,143 @@ 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
|
||||
}
|
||||
|
||||
192
e2e/wire_golden_test.go
Normal file
192
e2e/wire_golden_test.go
Normal file
@ -0,0 +1,192 @@
|
||||
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
|
||||
323
internal/billing/compile.go
Normal file
323
internal/billing/compile.go
Normal file
@ -0,0 +1,323 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// Compile turns one billing profile into the prices table the billing plugin
|
||||
// expects, resolving URL rules against the gateway's actual sources.
|
||||
//
|
||||
// WHY URL RULES NEED RESOLVING AT ALL: the operator declares pricing by URL
|
||||
// because that is what a provider's price list is keyed on, and because several
|
||||
// sources can point at the same URL. The plugin, however, looks up by
|
||||
// `payload.source` (a source NAME) and by model — it has no idea what URL a
|
||||
// request went to. So the URL match happens here, at compile time, where the
|
||||
// config's name->base_url mapping is known, and the result is expressed in the
|
||||
// dimensions the plugin already supports.
|
||||
//
|
||||
// Profiles are the "let the user choose" axis: the same URL can appear in
|
||||
// several profiles and switching recomputes this table, so a gateway can be
|
||||
// repriced without editing the plugin.
|
||||
func Compile(profile *config.BillingProfile, sources []config.Source) (map[string]interface{}, error) {
|
||||
return CompileOpts(profile, sources, false)
|
||||
}
|
||||
|
||||
// CompileOpts is Compile with an explicit policy for rules that match no
|
||||
// source.
|
||||
//
|
||||
// The default is STRICT and that is correct for startup: a profile with a
|
||||
// typo'd URL prices nothing, every request on it is recorded as unpriced, and
|
||||
// the bill silently comes out wrong. Failing the load is the right response.
|
||||
//
|
||||
// The rules editor needs the opposite. An operator editing a half-finished
|
||||
// profile — adding a rule before the source that will use it exists, or
|
||||
// renaming a URL while typing — must be able to save and then see the warning,
|
||||
// not be blocked by an error they can only resolve by guessing. Lenient mode
|
||||
// keeps the offending rule in the config and drops it from the compiled table,
|
||||
// so it is preserved as text and clearly reported, without pretending it
|
||||
// prices anything.
|
||||
func CompileOpts(profile *config.BillingProfile, sources []config.Source, lenient bool) (map[string]interface{}, error) {
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("no billing profile")
|
||||
}
|
||||
prices := map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
||||
"sources": map[string]interface{}{},
|
||||
"models": map[string]interface{}{},
|
||||
"keys": map[string]interface{}{},
|
||||
}
|
||||
if profile.Default != "" {
|
||||
prices["default_mode"] = profile.Default
|
||||
}
|
||||
|
||||
// Currency is a profile-level statement; a rule may override it.
|
||||
for i := range profile.Rules {
|
||||
if c := profile.Rules[i].Currency; c != "" {
|
||||
prices["currency"] = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
matched := map[string]bool{}
|
||||
for i := range profile.Rules {
|
||||
rule := &profile.Rules[i]
|
||||
targets := matchSources(rule.URL, sources)
|
||||
if len(targets) == 0 && rule.URL != "*" {
|
||||
// A rule for a URL no source uses is almost always a typo (or a
|
||||
// source that was removed). Failing loudly beats a profile that
|
||||
// silently prices nothing — the whole reason this is a config file
|
||||
// instead of a hand-written JSON blob.
|
||||
if lenient {
|
||||
continue // kept in the config text, reported as a warning
|
||||
}
|
||||
return nil, fmt.Errorf("rule url %q matches no configured source base_url", rule.URL)
|
||||
}
|
||||
for _, srcName := range targets {
|
||||
if matched[srcName] {
|
||||
// First rule wins. Two rules matching one source is ambiguous,
|
||||
// and silently letting the later one win makes the file's
|
||||
// meaning depend on ordering the operator cannot see.
|
||||
continue
|
||||
}
|
||||
matched[srcName] = true
|
||||
if err := applyRule(prices, rule, srcName, sources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sources no rule matched fall through to the profile default, which the
|
||||
// plugin applies via prices.default. Recording them explicitly means the
|
||||
// UI can say "this source is unpriced" instead of leaving the operator to
|
||||
// infer it from a zero.
|
||||
var unmatched []string
|
||||
for _, src := range sources {
|
||||
if !matched[src.Name] {
|
||||
unmatched = append(unmatched, src.Name)
|
||||
}
|
||||
}
|
||||
if len(unmatched) > 0 {
|
||||
prices["unmatched_sources"] = unmatched
|
||||
}
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
// matchSources returns the source names whose base_url matches pattern.
|
||||
// "*" matches every source (used as a catch-all default rule).
|
||||
func matchSources(pattern string, sources []config.Source) []string {
|
||||
var out []string
|
||||
if pattern == "*" {
|
||||
for _, s := range sources {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
want := normalizeURL(pattern)
|
||||
for _, s := range sources {
|
||||
if normalizeURL(s.BaseURL) == want {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeURL compares URLs the way an operator expects: trailing slashes and
|
||||
// case in the host are differences the provider's price list does not care
|
||||
// about, and requiring an exact byte match would make the config brittle.
|
||||
func normalizeURL(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
u = strings.TrimRight(u, "/")
|
||||
return strings.ToLower(u)
|
||||
}
|
||||
|
||||
// applyRule writes one rule's pricing for one source into the prices table.
|
||||
func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName string, sources []config.Source) error {
|
||||
switch rule.Mode {
|
||||
case "free":
|
||||
// Explicitly priced at zero. This is NOT the same as unpriced: a source
|
||||
// the operator says is free must not appear in the unpriced warnings,
|
||||
// or those warnings become noise and stop being read.
|
||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||
}
|
||||
return nil
|
||||
|
||||
case "subscription":
|
||||
// A fixed monthly commitment: the per-request MARGINAL cost is zero, and
|
||||
// the flat fee is reported separately. Spreading a monthly fee across
|
||||
// requests would invent a per-request number the provider never charges,
|
||||
// and it would change every time traffic did.
|
||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||
}
|
||||
fixed, _ := prices["subscriptions"].(map[string]interface{})
|
||||
if fixed == nil {
|
||||
fixed = map[string]interface{}{}
|
||||
prices["subscriptions"] = fixed
|
||||
}
|
||||
cur := "USD"
|
||||
if rule.Currency != "" {
|
||||
cur = rule.Currency
|
||||
}
|
||||
fixed[srcName] = map[string]interface{}{
|
||||
"monthly": rule.Subscription, "currency": cur,
|
||||
}
|
||||
return nil
|
||||
|
||||
case "unpriced":
|
||||
// Deliberately left out of `sources` so the plugin's unpriced_models /
|
||||
// unpriced_reqs counters catch it. That is the point: a subscription
|
||||
// plan whose credits cannot be converted to tokens must be VISIBLE as
|
||||
// unbilled, not quietly estimated.
|
||||
return nil
|
||||
|
||||
case "token":
|
||||
if len(rule.Models) == 0 {
|
||||
return fmt.Errorf("rule for url %q: mode token requires at least one model", rule.URL)
|
||||
}
|
||||
models := prices["models"].(map[string]interface{})
|
||||
for modelID, t := range rule.Models {
|
||||
entry := map[string]interface{}{}
|
||||
prompt, err := usdPerM(t.Prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q prompt: %w", modelID, err)
|
||||
}
|
||||
completion, err := usdPerM(t.Completion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q completion: %w", modelID, err)
|
||||
}
|
||||
entry["prompt"] = prompt
|
||||
entry["completion"] = completion
|
||||
if t.CacheDiscount != nil {
|
||||
entry["cache_discount"] = *t.CacheDiscount
|
||||
}
|
||||
// reasoning is optional and stays ABSENT (not nil, not 0) when the
|
||||
// rule does not set it, which is what makes the billing plugin skip
|
||||
// the repricing branch entirely. Writing an explicit 0 here would
|
||||
// mean "thinking is free" and silently under-bill every request
|
||||
// from a provider that bills it like ordinary output.
|
||||
if strings.TrimSpace(t.Reasoning) != "" {
|
||||
rp, err := usdPerM(t.Reasoning)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q reasoning: %w", modelID, err)
|
||||
}
|
||||
entry["reasoning_price"] = rp
|
||||
}
|
||||
if lc := t.LongContext; lc != nil {
|
||||
if lc.Above <= 0 {
|
||||
return fmt.Errorf("model %q long_context: above must be > 0", modelID)
|
||||
}
|
||||
above, err := longContextRate(lc, prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q long_context: %w", modelID, err)
|
||||
}
|
||||
entry["long_context"] = map[string]interface{}{
|
||||
"above": lc.Above,
|
||||
// Marginal, not blended. The plugin charges the tokens past the
|
||||
// threshold at this rate and the rest at the base rate, so the
|
||||
// threshold composes with a cache discount (which applies to the
|
||||
// cached portion) instead of forcing the plugin to guess how
|
||||
// the two interact.
|
||||
"prompt": above,
|
||||
}
|
||||
}
|
||||
if rule.Peak != nil {
|
||||
entry["peak"] = compilePeak(rule.Peak)
|
||||
}
|
||||
models[modelID] = entry
|
||||
}
|
||||
// A specific-URL token rule needs a source entry so a model served
|
||||
// from THIS url but absent from `rule.Models` is still recognised as
|
||||
// belonging to a priced provider.
|
||||
//
|
||||
// A WILDCARD rule ("*") must NOT get one. "*" is a model catalogue — it
|
||||
// says "these model ids have these prices, wherever they come from" —
|
||||
// and marking every source priced at {0,0} would make priceFor() set
|
||||
// priced=true for every model the catalogue does NOT list. Paid
|
||||
// sources (commandcode, alittokenplan, …) would then record $0 instead
|
||||
// of showing up in unpriced_reqs, which is precisely the silent
|
||||
// zero-billing failure this whole plugin is built to make visible.
|
||||
// The model lookup p.models[payload.model] already marks a listed
|
||||
// model priced on its own, so the wildcard loses nothing.
|
||||
if rule.URL != "*" {
|
||||
if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok {
|
||||
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
||||
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown mode %q", rule.Mode)
|
||||
}
|
||||
|
||||
// usdPerM converts a USD-per-million string into the per-token rate the plugin
|
||||
// expects. Delegates to config.ParseFloatUSDPerM so the DSL validator and the
|
||||
// compiler agree byte-for-byte on what a valid price is — a value that passes
|
||||
// Validate() but fails here (or vice versa) would be the worst kind of drift.
|
||||
func usdPerM(s string) (float64, error) {
|
||||
return config.ParseFloatUSDPerM(s)
|
||||
}
|
||||
|
||||
// compilePeak renders the peak window in the shape the plugin reads:
|
||||
// { multiplier, windows = { { days = {...}, hours = { {lo,hi}, ... } } } }.
|
||||
//
|
||||
// The plugin reads `days` / `hours` pairs. The first version of the deployed
|
||||
// price table used { start, end, weekdays } — a shape nothing reads — so peak
|
||||
// traffic was billed at off-peak rates with no error anywhere. Compiling from
|
||||
// typed config fields removes the chance of writing the wrong key names by hand.
|
||||
func compilePeak(p *config.BillingPeak) map[string]interface{} {
|
||||
win := map[string]interface{}{}
|
||||
if len(p.Weekdays) > 0 {
|
||||
days := make([]interface{}, 0, len(p.Weekdays))
|
||||
for _, d := range p.Weekdays {
|
||||
days = append(days, d)
|
||||
}
|
||||
win["days"] = days
|
||||
}
|
||||
if len(p.Hours) > 0 {
|
||||
hours := make([]interface{}, 0, len(p.Hours))
|
||||
for _, h := range p.Hours {
|
||||
hours = append(hours, []interface{}{h[0], h[1]})
|
||||
}
|
||||
win["hours"] = hours
|
||||
}
|
||||
out := map[string]interface{}{"multiplier": p.Multiplier}
|
||||
if len(win) > 0 {
|
||||
out["windows"] = []interface{}{win}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// longContextRate resolves the marginal per-token rate for the tokens ABOVE a
|
||||
// threshold, given the model's base per-million prompt price.
|
||||
//
|
||||
// Two ways to express it are supported because providers publish both: an
|
||||
// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek
|
||||
// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a
|
||||
// silent no-op, and an operator who wrote one meant something.
|
||||
func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) {
|
||||
hasPrompt := strings.TrimSpace(lc.Prompt) != ""
|
||||
hasMult := lc.Multiplier > 0
|
||||
switch {
|
||||
case hasPrompt && hasMult:
|
||||
p, err := usdPerM(lc.Prompt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Both given: the absolute rate wins, because it is the one the provider
|
||||
// actually quotes. Silently multiplying instead would produce a number
|
||||
// the operator never wrote.
|
||||
return p, nil
|
||||
case hasPrompt:
|
||||
return usdPerM(lc.Prompt)
|
||||
case hasMult:
|
||||
return basePerToken * lc.Multiplier, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("needs either prompt or multiplier")
|
||||
}
|
||||
}
|
||||
214
internal/billing/wildcard_test.go
Normal file
214
internal/billing/wildcard_test.go
Normal file
@ -0,0 +1,214 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// The wildcard token rule must not blanket-mark every source as priced.
|
||||
//
|
||||
// Why this is a hazard and not a style question: priceFor() sets priced=true as
|
||||
// soon as the request's SOURCE has an entry in the price table, and that flag
|
||||
// is what keeps a request OUT of unpriced_reqs. A "*" rule is a model
|
||||
// catalogue — it prices specific model ids wherever they come from — but
|
||||
// compiling it into a {0,0,0} entry on all 21 sources made every model the
|
||||
// catalogue does not list look "priced at zero". Paid providers (commandcode,
|
||||
// alittokenplan, …) then stopped appearing in the unpriced warnings, which is
|
||||
// exactly the silent zero-billing failure the plugin exists to surface.
|
||||
func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) {
|
||||
sources := []config.Source{
|
||||
{Name: "free1", BaseURL: "https://free1.example.com/v1"},
|
||||
{Name: "paid1", BaseURL: "https://paid1.example.com/v1"},
|
||||
{Name: "paid2", BaseURL: "https://paid2.example.com/v1"},
|
||||
}
|
||||
profile := &config.BillingProfile{
|
||||
ID: "p",
|
||||
Rules: []config.BillingRule{
|
||||
{URL: "https://free1.example.com/v1", Mode: "free"},
|
||||
{
|
||||
URL: "*", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"only-catalogued-model": {Prompt: "1.00", Completion: "2.00"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
prices, err := Compile(profile, sources)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
srcs := prices["sources"].(map[string]interface{})
|
||||
|
||||
// The explicitly free source keeps its entry (free is a deliberate 0).
|
||||
if _, ok := srcs["free1"]; !ok {
|
||||
t.Error("the explicit free rule lost its source entry")
|
||||
}
|
||||
// Paid sources must NOT be marked, or their unlisted models bill $0.
|
||||
for _, name := range []string{"paid1", "paid2"} {
|
||||
if _, ok := srcs[name]; ok {
|
||||
t.Errorf("wildcard token rule marked %q as priced at 0 — its unlisted "+
|
||||
"models will bill $0 instead of appearing as unpriced", name)
|
||||
}
|
||||
}
|
||||
// The catalogue itself must still be present, or the rule prices nothing.
|
||||
models := prices["models"].(map[string]interface{})
|
||||
if _, ok := models["only-catalogued-model"]; !ok {
|
||||
t.Error("the wildcard rule did not publish its model price")
|
||||
}
|
||||
}
|
||||
|
||||
// The inverse guard: a token rule for a SPECIFIC url still marks that source,
|
||||
// because it genuinely declares pricing for the models served from it.
|
||||
func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
|
||||
sources := []config.Source{
|
||||
{Name: "mine", BaseURL: "https://mine.example.com/v1"},
|
||||
{Name: "other", BaseURL: "https://other.example.com/v1"},
|
||||
}
|
||||
profile := &config.BillingProfile{
|
||||
ID: "p",
|
||||
Rules: []config.BillingRule{{
|
||||
URL: "https://mine.example.com/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
prices, err := Compile(profile, sources)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
srcs := prices["sources"].(map[string]interface{})
|
||||
if _, ok := srcs["mine"]; !ok {
|
||||
t.Error("a specific-url token rule must still mark its own source priced")
|
||||
}
|
||||
if _, ok := srcs["other"]; ok {
|
||||
t.Error("an unrelated source was marked priced by a specific-url rule")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the
|
||||
// thinking-token rate.
|
||||
//
|
||||
// A rule that says nothing about reasoning must produce a price entry with NO
|
||||
// reasoning_price key, so the billing plugin skips the repricing branch and
|
||||
// bills thinking at the ordinary output rate — which is what every provider
|
||||
// actually charges. Writing an explicit 0 instead would read as "thinking is
|
||||
// free" and silently under-bill every request from that model.
|
||||
func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) {
|
||||
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
|
||||
|
||||
// Case 1: no reasoning declared → the key must not appear.
|
||||
p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00"},
|
||||
},
|
||||
}}}
|
||||
prices, err := Compile(p1, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
if _, present := entry["reasoning_price"]; present {
|
||||
t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free",
|
||||
entry["reasoning_price"])
|
||||
}
|
||||
|
||||
// Case 2: declared → present, and converted to per-token like every other rate.
|
||||
p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"},
|
||||
},
|
||||
}}}
|
||||
prices2, err := Compile(p2, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
rp, ok := e2["reasoning_price"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"])
|
||||
}
|
||||
if rp != 6e-6 {
|
||||
t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp)
|
||||
}
|
||||
// A malformed rate must be rejected, not silently ignored: an operator who
|
||||
// typed "6 USD" expects either that value or an error, never a rule that
|
||||
// looks applied and bills nothing.
|
||||
p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"},
|
||||
},
|
||||
}}}
|
||||
if _, err := Compile(p3, sources); err == nil {
|
||||
t.Error("a non-numeric reasoning rate must fail the compile")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongContextStepCompiles pins the long-context step's two accepted forms
|
||||
// and its rejection of a step that would silently do nothing.
|
||||
//
|
||||
// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes
|
||||
// an absolute "$3.00/M past 200k" — so both must compile. Neither may be
|
||||
// missing: a step with no rate is a rule that LOOKS like it handles long
|
||||
// context and does not, which under-bills without any visible sign.
|
||||
func TestLongContextStepCompiles(t *testing.T) {
|
||||
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
|
||||
|
||||
compile := func(lc config.BillingLongContext) (map[string]interface{}, error) {
|
||||
p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc},
|
||||
},
|
||||
}}}
|
||||
prices, err := Compile(p, sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
return e["long_context"].(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
// Absolute rate (OpenAI's published shape).
|
||||
lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"})
|
||||
if err != nil {
|
||||
t.Fatalf("absolute long_context rate rejected: %v", err)
|
||||
}
|
||||
if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 {
|
||||
t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1)
|
||||
}
|
||||
|
||||
// Multiplier (DeepSeek's shape): 2x of the 1.00/M base.
|
||||
lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("multiplier long_context rejected: %v", err)
|
||||
}
|
||||
if lc2["prompt"] != 2e-6 {
|
||||
t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2)
|
||||
}
|
||||
|
||||
// Both given: the absolute rate wins, because that is the number the
|
||||
// provider quotes. Multiplying instead would produce a figure the operator
|
||||
// never wrote.
|
||||
lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lc3["prompt"] != 5e-6 {
|
||||
t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3)
|
||||
}
|
||||
|
||||
// Neither: a no-op step must fail, not compile into a rule that silently
|
||||
// bills long context at the base rate.
|
||||
if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil {
|
||||
t.Error("a step with neither prompt nor multiplier must be rejected")
|
||||
}
|
||||
// A zero threshold would make every request "long".
|
||||
if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil {
|
||||
t.Error("above=0 must be rejected")
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@ -19,14 +21,38 @@ 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
|
||||
PublicBaseURL string `yaml:"public_base_url,omitempty"` // external base for generated config snippets; default inferred from request
|
||||
Sources []Source `yaml:"sources"`
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
|
||||
// BillingDSL declares per-URL pricing profiles for the billing plugin.
|
||||
//
|
||||
// Declarative, because hand-writing the prices JSON was error-prone in ways
|
||||
// that looked successful: the first PUT went out without the required
|
||||
// `prices` wrapper and silently REPLACED the accumulated totals with the
|
||||
// price table; the second used a peak-window shape the plugin does not read,
|
||||
// so peak traffic was billed at off-peak rates — no error, the bill just
|
||||
// quietly halved. A schema-checked config file catches both at load.
|
||||
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
|
||||
// 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.
|
||||
@ -47,15 +73,24 @@ type Model struct {
|
||||
|
||||
// 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
|
||||
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
|
||||
// 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"`
|
||||
// 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"`
|
||||
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"`
|
||||
@ -123,6 +158,7 @@ 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)
|
||||
@ -188,10 +224,15 @@ 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.
|
||||
func UpsertSourceInYAML(path, name string, src Source) error {
|
||||
// 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 {
|
||||
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
|
||||
@ -249,11 +290,17 @@ func UpsertSourceInYAML(path, name string, src Source) 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)
|
||||
}
|
||||
@ -268,6 +315,9 @@ 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"
|
||||
}
|
||||
@ -314,55 +364,306 @@ func (c *Config) ApplyDefaults() error {
|
||||
|
||||
// RuntimeConfig is the legacy runtime file format (kept for migration only).
|
||||
type RuntimeConfig struct {
|
||||
Sources []Source `json:"sources,omitempty"`
|
||||
SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
|
||||
DeletedSources []string `json:"deleted_sources,omitempty"`
|
||||
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
|
||||
Keys []GWKey `json:"keys,omitempty"`
|
||||
Auto []ModelScope `json:"auto,omitempty"`
|
||||
AutoImage []ModelScope `json:"auto_image,omitempty"`
|
||||
Sources []Source `json:"sources,omitempty"`
|
||||
SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
|
||||
DeletedSources []string `json:"deleted_sources,omitempty"`
|
||||
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
|
||||
PresetTemplates []string `json:"preset_templates,omitempty"` // preset names the user has seen (or deleted) — never re-seeded
|
||||
Keys []GWKey `json:"keys,omitempty"`
|
||||
Auto []ModelScope `json:"auto,omitempty"`
|
||||
AutoImage []ModelScope `json:"auto_image,omitempty"`
|
||||
}
|
||||
|
||||
// SourceTemplate stores reusable source configuration (everything except
|
||||
// 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"`
|
||||
ImageEndpoint string `json:"image_endpoint,omitempty"`
|
||||
Models []Model `json:"models"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Meta map[string]interface{} `json:"meta,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
MaxConcurrent int `json:"max_concurrent,omitempty"`
|
||||
RPM int `json:"rpm,omitempty"`
|
||||
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"`
|
||||
// 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"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
MaxConcurrent int `json:"max_concurrent,omitempty"`
|
||||
RPM int `json:"rpm,omitempty"`
|
||||
}
|
||||
|
||||
// GWKey is a gateway API key persisted in the config file. Role is "admin"
|
||||
// (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"`
|
||||
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"`
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 }
|
||||
|
||||
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
|
||||
//
|
||||
// Profiles are the "let the user pick" axis: the same upstream URL can be
|
||||
// declared under multiple profiles, and the operator chooses which is active.
|
||||
// Switching recomputes and re-injects the billing plugin's prices table.
|
||||
type BillingDSL struct {
|
||||
// Active is the id of the profile in effect. Empty => the first profile.
|
||||
Active string `yaml:"active,omitempty" json:"active,omitempty"`
|
||||
Profiles []BillingProfile `yaml:"profiles,omitempty" json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// BillingProfile is one named set of per-URL pricing rules.
|
||||
type BillingProfile struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Label string `yaml:"label,omitempty" json:"label,omitempty"`
|
||||
// Default applies when no rule matches: "free" or "unpriced".
|
||||
Default string `yaml:"default,omitempty" json:"default,omitempty"`
|
||||
Rules []BillingRule `yaml:"rules,omitempty" json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
// BillingRule declares the pricing for everything under one URL.
|
||||
type BillingRule struct {
|
||||
// URL matches a source's base_url. "*" = any unmatched URL.
|
||||
URL string `yaml:"url" json:"url"`
|
||||
// Mode is one of: free, token, subscription, unpriced.
|
||||
Mode string `yaml:"mode" json:"mode"`
|
||||
Currency string `yaml:"currency,omitempty" json:"currency,omitempty"`
|
||||
// Subscription is the flat monthly cost (for mode=subscription).
|
||||
Subscription float64 `yaml:"subscription,omitempty" json:"subscription,omitempty"`
|
||||
// Models declares per-token prices when mode=token.
|
||||
Models map[string]BillingToken `yaml:"models,omitempty" json:"models,omitempty"`
|
||||
// Free is true when mode=free (kept for explicitness).
|
||||
// Peak applies to ALL token-priced requests under this URL.
|
||||
Peak *BillingPeak `yaml:"peak,omitempty" json:"peak,omitempty"`
|
||||
}
|
||||
|
||||
// BillingToken is the per-model price.
|
||||
type BillingToken struct {
|
||||
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
|
||||
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
|
||||
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
|
||||
// Reasoning is the USD-per-million rate for thinking tokens, for the rare
|
||||
// provider that charges them differently from ordinary output. Omit it and
|
||||
// reasoning is billed at Completion — which is what OpenAI, DeepSeek and
|
||||
// Anthropic all do, and what every source on this deployment reports.
|
||||
//
|
||||
// It is a rate for a SUBSET of completion tokens, never an extra charge: the
|
||||
// billing plugin applies only the difference between this and Completion, so
|
||||
// setting it to a multiple of Completion reprices the thinking share rather
|
||||
// than billing it twice.
|
||||
Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
// LongContext is the prompt-price step above a token threshold. Nil = one
|
||||
// flat rate, which is what a rule means when it says nothing.
|
||||
LongContext *BillingLongContext `yaml:"long_context,omitempty" json:"long_context,omitempty"`
|
||||
}
|
||||
|
||||
// BillingPeak declares peak-hour pricing.
|
||||
type BillingPeak struct {
|
||||
Multiplier float64 `yaml:"multiplier" json:"multiplier"`
|
||||
// Weekdays: 1=Mon..5=Fri (Lua os.date !%w, 0=Sun)
|
||||
Weekdays []int `yaml:"weekdays,omitempty" json:"weekdays,omitempty"`
|
||||
// Hours: each pair is [lo,hi] inclusive
|
||||
Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
// BillingLongContext is a prompt-price step for long inputs.
|
||||
//
|
||||
// Every major provider charges the input tokens ABOVE a threshold at a higher
|
||||
// rate — DeepSeek doubles past 128k, OpenAI and Anthropic step up as well. A
|
||||
// flat prompt price therefore under-bills exactly the traffic an agent workload
|
||||
// generates, and it under-bills it silently: the number is plausible, the bill
|
||||
// adds up, and nothing says the rate was wrong.
|
||||
//
|
||||
// The step applies to the PROMPT only. Output and cache reads are billed at
|
||||
// their own rates regardless of input length, which is how providers publish
|
||||
// it — the higher rate is for the extra context you made the model read, not for
|
||||
// what it wrote back.
|
||||
type BillingLongContext struct {
|
||||
// Above is the token count the step starts at. A request with exactly this
|
||||
// many prompt tokens is still at the base rate; the step covers the tokens
|
||||
// BEYOND it. Inclusive-below matches how the providers document it.
|
||||
Above int `yaml:"above" json:"above"`
|
||||
// Prompt is the USD-per-million rate for the tokens above Above.
|
||||
Prompt string `yaml:"prompt" json:"prompt"`
|
||||
// Multiplier is an alternative to Prompt: a factor applied to the base rate
|
||||
// (DeepSeek's published step is exactly 2x). When both are set Prompt wins,
|
||||
// and setting neither is a validation error rather than a silent no-op.
|
||||
Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
|
||||
}
|
||||
|
||||
// Validate rejects a BillingDSL that could not work as written.
|
||||
func (b *BillingDSL) Validate() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for i := range b.Profiles {
|
||||
p := &b.Profiles[i]
|
||||
if p.ID == "" {
|
||||
return fmt.Errorf("billing profile [%d] has no id", i)
|
||||
}
|
||||
if ids[p.ID] {
|
||||
return fmt.Errorf("billing profile id %q appears more than once", p.ID)
|
||||
}
|
||||
ids[p.ID] = true
|
||||
if p.Default != "" && p.Default != "free" && p.Default != "unpriced" {
|
||||
return fmt.Errorf("profile %q: default must be free or unpriced, got %q", p.ID, p.Default)
|
||||
}
|
||||
for j := range p.Rules {
|
||||
r := &p.Rules[j]
|
||||
if r.URL == "" {
|
||||
return fmt.Errorf("profile %q rule [%d]: url is required", p.ID, j)
|
||||
}
|
||||
switch r.Mode {
|
||||
case "free", "token", "subscription", "unpriced":
|
||||
default:
|
||||
return fmt.Errorf("profile %q rule [%d]: mode must be free/token/subscription/unpriced, got %q", p.ID, j, r.Mode)
|
||||
}
|
||||
if r.Mode == "token" {
|
||||
for m, t := range r.Models {
|
||||
if t.Prompt == "" || t.Completion == "" {
|
||||
return fmt.Errorf("profile %q model %q: prompt and completion are required for token mode", p.ID, m)
|
||||
}
|
||||
if _, err := ParseFloatUSDPerM(t.Prompt); err != nil {
|
||||
return fmt.Errorf("profile %q model %q prompt: %w", p.ID, m, err)
|
||||
}
|
||||
if _, err := ParseFloatUSDPerM(t.Completion); err != nil {
|
||||
return fmt.Errorf("profile %q model %q completion: %w", p.ID, m, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Active must resolve to a profile id.
|
||||
if b.Active != "" && !ids[b.Active] {
|
||||
return fmt.Errorf("billing.active = %q but no profile has that id", b.Active)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve returns the profile with the given id; empty id (or an id that does
|
||||
// not exist) falls back to the FIRST profile. A config with profiles but no
|
||||
// active marker should mean "use the one I wrote first" rather than "price
|
||||
// nothing", which is what a nil here would silently do.
|
||||
func (b *BillingDSL) Resolve(id string) *BillingProfile {
|
||||
if b == nil || len(b.Profiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
if id != "" {
|
||||
for i := range b.Profiles {
|
||||
if b.Profiles[i].ID == id {
|
||||
return &b.Profiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return &b.Profiles[0]
|
||||
}
|
||||
|
||||
// parseFloatUSDPerM parses a USD-per-million price string. Returns the
|
||||
// per-single-token rate the plugin expects (price / 1e6).
|
||||
//
|
||||
// Kept as a string in the DSL rather than a float: "0.15" survives round-trips
|
||||
// through YAML editors, and a malformed value must fail VALIDATION (with the
|
||||
// file and field named) rather than parse as 0 and under-bill silently — which
|
||||
// is exactly the failure mode that motivated the DSL.
|
||||
func ParseFloatUSDPerM(s string) (float64, error) {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("not a number: %q", s)
|
||||
}
|
||||
if f < 0 {
|
||||
return 0, fmt.Errorf("negative price: %q", s)
|
||||
}
|
||||
return f / 1e6, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
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"`
|
||||
}
|
||||
@ -381,6 +682,7 @@ 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"`
|
||||
}
|
||||
@ -391,6 +693,7 @@ 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
|
||||
|
||||
@ -318,3 +318,55 @@ sources:
|
||||
t.Fatalf("after no-op: %v %v", len(cfg.Sources), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedTemplatesOnce verifies preset templates are seeded exactly once:
|
||||
// a user's edit survives a restart, and a deliberately deleted preset does
|
||||
// not silently come back.
|
||||
func TestSeedTemplatesOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "runtime.json")
|
||||
presets := []SourceTemplate{
|
||||
{Name: "DeepSeek", BaseURL: "https://api.deepseek.com", Adapter: "deepseek",
|
||||
Models: []Model{{ID: "deepseek-chat", Kind: "chat"}}},
|
||||
{Name: "OpenAI", BaseURL: "https://api.openai.com/v1", Adapter: "openai",
|
||||
Models: []Model{{ID: "gpt-4o", Kind: "chat"}}},
|
||||
}
|
||||
|
||||
// first run: both presets land in the store
|
||||
s1 := NewStore(path)
|
||||
if err := s1.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.SeedTemplates(presets); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if got := len(s1.ListTemplates()); got != 2 {
|
||||
t.Fatalf("after seed len = %d, want 2", got)
|
||||
}
|
||||
|
||||
// user edits one preset, deletes the other
|
||||
edited := presets[0]
|
||||
edited.BaseURL = "https://my-proxy.internal/v1"
|
||||
if err := s1.UpsertTemplate(edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s1.RemoveTemplate("OpenAI"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// restart: re-seeding must be a no-op for both
|
||||
s2 := NewStore(path)
|
||||
if err := s2.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s2.SeedTemplates(presets); err != nil {
|
||||
t.Fatalf("reseed: %v", err)
|
||||
}
|
||||
tpls := s2.ListTemplates()
|
||||
if len(tpls) != 1 {
|
||||
t.Fatalf("after restart len = %d, want 1 (deleted preset must not return): %+v", len(tpls), tpls)
|
||||
}
|
||||
if tpls[0].Name != "DeepSeek" || tpls[0].BaseURL != "https://my-proxy.internal/v1" {
|
||||
t.Fatalf("user edit lost on restart: %+v", tpls[0])
|
||||
}
|
||||
}
|
||||
|
||||
212
internal/config/presets.go
Normal file
212
internal/config/presets.go
Normal file
@ -0,0 +1,212 @@
|
||||
package config
|
||||
|
||||
// Built-in preset source templates. Seeded into the runtime store on first
|
||||
// startup so the WebUI can present "从模板创建" for popular providers — the
|
||||
// user only needs to supply a name and API key.
|
||||
//
|
||||
// Model IDs verified against provider docs 2026-08-28. Providers ship new IDs
|
||||
// frequently; treat these as a starting point and edit in the WebUI as needed.
|
||||
var PresetTemplates = []SourceTemplate{
|
||||
// ── 国内热门 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
Name: "DeepSeek",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Adapter: "deepseek",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "智谱 GLM",
|
||||
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Kimi / Moonshot",
|
||||
BaseURL: "https://api.moonshot.cn/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "SiliconFlow",
|
||||
BaseURL: "https://api.siliconflow.cn/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "讯飞星火 (Spark)",
|
||||
BaseURL: "https://spark-api-open.xf-yun.com/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "4.0Ultra", Priority: 100, Kind: "chat"},
|
||||
{ID: "generalv3.5", Priority: 80, Kind: "chat"},
|
||||
{ID: "max-32k", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "阿里云 通义千问",
|
||||
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "百度 千帆",
|
||||
BaseURL: "https://qianfan.baidubce.com/v2",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "豆包 (火山方舟)",
|
||||
BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "MiniMax",
|
||||
BaseURL: "https://api.minimax.chat/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "MiniMax-M3", Priority: 100, Kind: "chat"},
|
||||
{ID: "MiniMax-M2.5", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 国际主流 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
Name: "OpenAI",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Anthropic Claude",
|
||||
BaseURL: "https://api.anthropic.com",
|
||||
Adapter: "anthropic",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Google Gemini",
|
||||
BaseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
Adapter: "gemini",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Groq",
|
||||
BaseURL: "https://api.groq.com/openai/v1",
|
||||
Adapter: "groq",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Mistral",
|
||||
BaseURL: "https://api.mistral.ai/v1",
|
||||
Adapter: "mistral",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "mistral-medium-latest", Priority: 100, Kind: "chat"},
|
||||
{ID: "mistral-large-latest", Priority: 90, Kind: "chat"},
|
||||
{ID: "codestral-latest", Priority: 85, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OpenRouter",
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
Adapter: "openai",
|
||||
MaxConcurrent: 8,
|
||||
Headers: map[string]string{
|
||||
"HTTP-Referer": "http://localhost:8080",
|
||||
"X-Title": "ModelRouter",
|
||||
},
|
||||
Models: []Model{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GitHub Models",
|
||||
BaseURL: "https://models.inference.ai.azure.com",
|
||||
Adapter: "github",
|
||||
MaxConcurrent: 8,
|
||||
Models: []Model{
|
||||
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
|
||||
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
// ── 本地模型 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
Name: "Ollama (localhost)",
|
||||
BaseURL: "http://127.0.0.1:11434",
|
||||
Adapter: "ollama",
|
||||
Endpoint: "/api/chat",
|
||||
MaxConcurrent: 4,
|
||||
Models: []Model{
|
||||
{ID: "qwen3", Priority: 80, Kind: "chat"},
|
||||
{ID: "deepseek-r1", Priority: 80, Kind: "chat"},
|
||||
{ID: "llama3.3", Priority: 70, Kind: "chat"},
|
||||
},
|
||||
},
|
||||
}
|
||||
317
internal/config/secret_config.go
Normal file
317
internal/config/secret_config.go
Normal file
@ -0,0 +1,317 @@
|
||||
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
|
||||
}
|
||||
323
internal/config/secret_config_test.go
Normal file
323
internal/config/secret_config_test.go
Normal file
@ -0,0 +1,323 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -138,6 +138,38 @@ func (s *Store) DeletedAdapters() map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// SeedTemplates adds preset templates that the store has never seen before.
|
||||
// A preset is seeded at most once: its name is recorded in PresetTemplates so
|
||||
// a user who edits or deletes it never gets it silently restored on restart.
|
||||
func (s *Store) SeedTemplates(presets []SourceTemplate) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
seen := make(map[string]bool, len(s.data.PresetTemplates))
|
||||
for _, n := range s.data.PresetTemplates {
|
||||
seen[n] = true
|
||||
}
|
||||
existing := make(map[string]bool, len(s.data.SourceTemplates))
|
||||
for _, t := range s.data.SourceTemplates {
|
||||
existing[t.Name] = true
|
||||
}
|
||||
added := false
|
||||
for _, p := range presets {
|
||||
if p.Name == "" || seen[p.Name] {
|
||||
continue
|
||||
}
|
||||
s.data.PresetTemplates = append(s.data.PresetTemplates, p.Name)
|
||||
added = true
|
||||
if existing[p.Name] {
|
||||
continue // user already has a template by this name: never overwrite
|
||||
}
|
||||
s.data.SourceTemplates = append(s.data.SourceTemplates, p)
|
||||
}
|
||||
if !added {
|
||||
return nil
|
||||
}
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
// UpsertTemplate adds or replaces a source template and persists.
|
||||
func (s *Store) UpsertTemplate(t SourceTemplate) error {
|
||||
s.mu.Lock()
|
||||
|
||||
102
internal/core/channels_account_adopt_test.go
Normal file
102
internal/core/channels_account_adopt_test.go
Normal file
@ -0,0 +1,102 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// account_adopt:把升级前的**非池**凭据接管进账号池。
|
||||
//
|
||||
// 为什么需要它:v2.0.x 之前登录只写一条 `channels:<plat>`,引入账号池后凭据落在
|
||||
// `channels:<plat>:<aid>`。升级后老凭据仍在保险库、但池里没有账号引用它 ——
|
||||
// 表现为"账号消失了"(数据其实还在)。让用户重新登录是**可见的额外代价**。
|
||||
//
|
||||
// ⚠️ 本判据同时钉住一条操作纪律:**接管必须走插件的保险库 API**(读出的是明文,
|
||||
// 写回时插件自己加密)。绝不要把保险库文件里的密文当明文喂给 API ——
|
||||
// 保险库读取时会解密,那样会把凭据**二次加密**(实测踩过:1971 → 2965 字符,
|
||||
// 该账号推理开始返回空)。
|
||||
func TestAccountAdoptLegacyCredential(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
const legacy = `{"access_token":"LEGACY-TOK","refresh_token":"RT","nickname":"老账号"}`
|
||||
|
||||
// 造一条"旧版"非池凭据(模拟升级前留下的)
|
||||
if _, err := ps.Call("channels", "__set_legacy_cred_probe", map[string]interface{}{
|
||||
"platform": "buddy", "credential": legacy,
|
||||
}); err != nil {
|
||||
t.Fatalf("造旧凭据: %v", err)
|
||||
}
|
||||
|
||||
// 接管进一个新池账号
|
||||
m, err := ps.Call("channels", "account_adopt", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "adopted1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_adopt: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["ok"] != true {
|
||||
t.Fatalf("接管失败: %#v", m)
|
||||
}
|
||||
|
||||
// ① 池里必须出现该账号,且**有凭据**
|
||||
lm, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
|
||||
lj, _ := lm.(map[string]interface{})["json"].(map[string]interface{})
|
||||
found := false
|
||||
for _, a := range asArrInterface(lj["accounts"]) {
|
||||
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
|
||||
found = true
|
||||
if am["has_credential"] != true {
|
||||
t.Errorf("接管后应 has_credential=true,实得 %v", am["has_credential"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("接管后账号未出现在池里: %#v", lj["accounts"])
|
||||
}
|
||||
|
||||
// ② 凭据必须是**原文**(未被二次加密)——这是本判据的核心
|
||||
out, err := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||||
"ref": "channels:buddy:adopted1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("读凭据: %v", err)
|
||||
}
|
||||
rj, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
got, _ := rj["credential"].(string)
|
||||
if got != legacy {
|
||||
t.Errorf("接管后的凭据应与原文逐字节相同(不得二次加密)\n 期望: %s\n 实得: %s",
|
||||
legacy, got)
|
||||
}
|
||||
if rj["len"] != nil {
|
||||
if n, _ := rj["len"].(float64); int(n) != len(legacy) {
|
||||
t.Errorf("凭据长度 %v,应为 %d(长度变化说明被二次加密)", n, len(legacy))
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 补齐了展示字段(昵称从凭据里取)
|
||||
for _, a := range asArrInterface(lj["accounts"]) {
|
||||
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
|
||||
if am["nickname"] != "老账号" {
|
||||
t.Errorf("昵称应从凭据补齐为「老账号」,实得 %v", am["nickname"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 没有旧凭据时必须如实报错(不能假装成功)
|
||||
_, _ = ps.Call("channels", "__clear_all_creds_probe", nil)
|
||||
m2, _ := ps.Call("channels", "account_adopt", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "adopted2",
|
||||
})
|
||||
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j2["ok"] == true {
|
||||
t.Error("没有旧凭据时应如实失败,不能假装成功")
|
||||
}
|
||||
}
|
||||
|
||||
// asArrInterface 宽松取数组(插件桥回传的可能是 []interface{})。
|
||||
func asArrInterface(v interface{}) []interface{} {
|
||||
if a, ok := v.([]interface{}); ok {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
}
|
||||
105
internal/core/channels_account_probe_test.go
Normal file
105
internal/core/channels_account_probe_test.go
Normal file
@ -0,0 +1,105 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// `account_test` 必须**真打一次上游**。
|
||||
//
|
||||
// 真实缺陷:此前它一个网络请求都不发 —— 只 syncAccountSource 后查内核源列表里
|
||||
// 有没有那条记录,就返回 {ok:true, ms:1}。于是"测试账号"永远绿,包括凭据已失效、
|
||||
// 被限流、上游宕机的情况。这正是「看着能用、一用就错」的假阳性,也正是用户报
|
||||
// 「完全不可用」时最容易误导人的那种信号。
|
||||
//
|
||||
// 判据把三条都钉住:① 真发请求 ② 上游失败时必须 ok=false ③ 失败要区分
|
||||
// "凭据失效"(需重新登录)与"暂时性失败"(可重试)—— 只说"失败"用户不知道该做什么。
|
||||
func TestAccountTestReallyProbesUpstream(t *testing.T) {
|
||||
var hits int32
|
||||
fail := false
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if fail {
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant: token expired"}`))
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"data": map[string]interface{}{"Response": map[string]interface{}{
|
||||
"Data": map[string]interface{}{"Accounts": []map[string]interface{}{
|
||||
{"Status": 1, "CycleCapacityRemain": 100},
|
||||
}},
|
||||
}},
|
||||
})
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"balance": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "probeacct",
|
||||
"credential": `{"access_token":"TOK"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
|
||||
// ① 正常路径:必须真的打了上游
|
||||
atomic.StoreInt32(&hits, 0)
|
||||
m, err := ps.Call("channels", "account_test", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "probeacct",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_test: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j["ok"] != true {
|
||||
t.Fatalf("正常凭据应 ok=true: %#v", j)
|
||||
}
|
||||
if j["probed"] != true {
|
||||
t.Errorf("必须标记 probed=true 并真的发请求;实得 probed=%v", j["probed"])
|
||||
}
|
||||
if n := atomic.LoadInt32(&hits); n == 0 {
|
||||
t.Fatal("account_test 未向上游发任何请求 —— 这就是「看着能用」的假阳性")
|
||||
}
|
||||
|
||||
// ② 凭据失效:必须 ok=false,且归类为 auth(给出可操作提示)
|
||||
fail = true
|
||||
m2, _ := ps.Call("channels", "account_test", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "probeacct",
|
||||
})
|
||||
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j2["ok"] != false {
|
||||
t.Errorf("凭据失效时必须 ok=false,实得 %v", j2["ok"])
|
||||
}
|
||||
if j2["kind"] != "auth" {
|
||||
t.Errorf("应归类为 auth(需重新登录),实得 %v", j2["kind"])
|
||||
}
|
||||
if s, _ := j2["action"].(string); s == "" {
|
||||
t.Error("失败必须给出可操作提示(否则用户不知道要重新登录还是等一会)")
|
||||
}
|
||||
|
||||
// ③ 无凭据的账号:如实报错,不假装成功
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "naked",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add(无凭据): %v", err)
|
||||
}
|
||||
m3, _ := ps.Call("channels", "account_test", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "naked",
|
||||
})
|
||||
j3, _ := m3.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j3["ok"] != false {
|
||||
t.Errorf("无凭据时必须 ok=false,实得 %v", j3["ok"])
|
||||
}
|
||||
}
|
||||
182
internal/core/channels_authorize_test.go
Normal file
182
internal/core/channels_authorize_test.go
Normal file
@ -0,0 +1,182 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// callback 平台的授权 URL 是最容易"看起来对、实际不可用"的地方:URL 能拼出来、
|
||||
// 页面能打开,但参数名/编码/主机错一处,上游就拒绝授权。
|
||||
//
|
||||
// 判据照参考实现(trae-oauth.ts / gemini-oauth.ts / lobsterai-oauth.ts)逐条锁定:
|
||||
// - Trae 参数名必须是 auth_callback_url(不是 redirect_uri)
|
||||
// - Gemini redirect_uri 主机必须是 localhost(不是 127.0.0.1),token 交换要一致
|
||||
// - LobsterAI 的 login 在 **hash 段**(/portal#/login),三个参数用 ? 起
|
||||
// - redirect_uri 必须百分号编码(含 :// 与 :)
|
||||
func TestCallbackAuthorizeUrls(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := func(t *testing.T, plat string) (string, map[string]interface{}) {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s start_login: %v", plat, err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
u, _ := j["url"].(string)
|
||||
return u, j
|
||||
}
|
||||
|
||||
t.Run("行1 trae 用 auth_callback_url 而非 redirect_uri", func(t *testing.T) {
|
||||
u, _ := start(t, "trae")
|
||||
if u == "" {
|
||||
t.Fatal("trae 未给出授权 URL")
|
||||
}
|
||||
if !strings.Contains(u, "auth_callback_url=") {
|
||||
t.Fatalf("trae 必须用 auth_callback_url(参考注释点名:错名则登录必失败): %s", u)
|
||||
}
|
||||
if strings.Contains(u, "redirect_uri=") {
|
||||
t.Fatalf("trae 不该有 redirect_uri 参数: %s", u)
|
||||
}
|
||||
for _, want := range []string{"client_id=en1oxy7wnw8j9n", "auth_type=local", "login_channel=native_ide"} {
|
||||
if !strings.Contains(u, want) {
|
||||
t.Fatalf("trae 缺参数 %s: %s", want, u)
|
||||
}
|
||||
}
|
||||
// 关键:不能再出现 "…/authorization&redirect_uri=" 这种坏的拼接
|
||||
if strings.Contains(u, "authorization&") {
|
||||
t.Fatalf("授权 URL 首参分隔符必须是 ? 而不是 &: %s", u)
|
||||
}
|
||||
// 参考 trae-oauth.ts 把参数集列为「一个都不能少」(早期只发 5 个参数,
|
||||
// 登录页**永远停在授权中**,既不跳转也不回传)。逐条锁定:
|
||||
// login_trace_id 是回调反查 pending 的唯一凭据;machine/device 身份除外
|
||||
// 还可能被风控拦;x_* 是客户端形态伪装。
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
t.Fatalf("trae 授权 URL 无法解析: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
for _, k := range []string{
|
||||
"login_version", "auth_from", "login_channel", "plugin_version",
|
||||
"auth_type", "client_id", "redirect", "login_trace_id",
|
||||
"auth_callback_url", "machine_id", "device_id",
|
||||
"x_device_id", "x_machine_id", "x_device_brand", "x_device_type",
|
||||
"x_os_version", "x_app_version", "x_app_type",
|
||||
} {
|
||||
if q.Get(k) == "" {
|
||||
t.Fatalf("trae 缺参数 %s(参考列为必需): %s", k, u)
|
||||
}
|
||||
}
|
||||
// 设备身份是 hex32,trace_id 是它们拼接后的末 16 位
|
||||
m, d := q.Get("machine_id"), q.Get("device_id")
|
||||
if len(m) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(m) {
|
||||
t.Fatalf("machine_id 应为 hex32: %q", m)
|
||||
}
|
||||
if len(d) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d) {
|
||||
t.Fatalf("device_id 应为 hex32: %q", d)
|
||||
}
|
||||
if m == d {
|
||||
t.Fatalf("machine_id 与 device_id 不应相同(同源会削弱设备身份)")
|
||||
}
|
||||
joined := m + d
|
||||
if got := q.Get("login_trace_id"); got != joined[len(joined)-16:] {
|
||||
t.Fatalf("login_trace_id 应为 (machine_id+device_id) 末16位,实得 %q", got)
|
||||
}
|
||||
// 同一平台两次登录必须得到不同设备身份(否则多账号共用设备,
|
||||
// 会被上游以「该设备已签到」拦截)
|
||||
u2, _ := start(t, "trae")
|
||||
q2, _ := url.Parse(u2)
|
||||
if q2.Query().Get("device_id") == d {
|
||||
t.Fatalf("两次登录的 device_id 应不同(每账号独立设备身份)")
|
||||
}
|
||||
// Trae 注册的回调路径是 /authorize(不是插件的 /api/plugins/http 挂载点):
|
||||
// 回环地址由「打开授权页的那台机器」解析,所以必须是本机 loopback 接收器。
|
||||
if cb := q.Get("auth_callback_url"); cb != "" {
|
||||
if !strings.HasSuffix(cb, "/authorize") {
|
||||
t.Fatalf("trae auth_callback_url 必须以注册路径 /authorize 结尾,实得 %q", cb)
|
||||
}
|
||||
if !strings.HasPrefix(cb, "http://127.0.0.1:") {
|
||||
t.Fatalf("trae auth_callback_url 应是 127.0.0.1 回环地址,实得 %q", cb)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 gemini 主机是 localhost 且参数不重复", func(t *testing.T) {
|
||||
u, _ := start(t, "gemini")
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
t.Fatalf("授权 URL 无法解析: %v", err)
|
||||
}
|
||||
ru := parsed.Query().Get("redirect_uri")
|
||||
if !strings.HasPrefix(ru, "http://localhost:") {
|
||||
t.Fatalf("gemini redirect_uri 主机必须是 localhost(参考注释明确): %q", ru)
|
||||
}
|
||||
// 路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH)——
|
||||
// 换成别的路径就是 redirect_uri_mismatch。
|
||||
if !strings.HasSuffix(ru, "/oauth-callback") {
|
||||
t.Fatalf("gemini redirect_uri 路径必须是 /oauth-callback,实得 %q", ru)
|
||||
}
|
||||
for _, k := range []string{"client_id", "response_type", "scope", "state"} {
|
||||
if parsed.Query().Get(k) == "" {
|
||||
t.Fatalf("gemini 缺参数 %s: %s", k, u)
|
||||
}
|
||||
}
|
||||
// 缺 access_type=offline 拿不到 refresh_token;prompt=consent 保证每次都发。
|
||||
if got := parsed.Query().Get("access_type"); got != "offline" {
|
||||
t.Fatalf("gemini access_type 应为 offline(否则无 refresh_token),实得 %q", got)
|
||||
}
|
||||
if got := parsed.Query().Get("prompt"); got != "consent" {
|
||||
t.Fatalf("gemini prompt 应为 consent,实得 %q", got)
|
||||
}
|
||||
// 参数不得重复(通用分支曾再追加一次 redirect_uri)
|
||||
if n := strings.Count(u, "redirect_uri="); n != 1 {
|
||||
t.Fatalf("redirect_uri 出现 %d 次(应恰好 1 次): %s", n, u)
|
||||
}
|
||||
if n := strings.Count(u, "client_id="); n != 1 {
|
||||
t.Fatalf("client_id 出现 %d 次: %s", n, u)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 lobsterai 的 login 在 hash 段且参数编码", func(t *testing.T) {
|
||||
u, _ := start(t, "lobsterai")
|
||||
if !strings.Contains(u, "/portal#/login?") {
|
||||
t.Fatalf("lobsterai 授权页必须是 /portal#/login?…(login 在 hash 段): %s", u)
|
||||
}
|
||||
if strings.Contains(u, "/login&") {
|
||||
t.Fatalf("首参分隔符必须是 ? 而不是 &: %s", u)
|
||||
}
|
||||
if !strings.Contains(u, "source=electron") {
|
||||
t.Fatalf("lobsterai 缺 source=electron: %s", u)
|
||||
}
|
||||
// redirect_uri 必须编码(含 :// 与 :),否则登录页校验失败
|
||||
if !strings.Contains(u, "redirect_uri=http%3A%2F%2F") {
|
||||
t.Fatalf("lobsterai redirect_uri 必须百分号编码: %s", u)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 端口用本机监听值而非硬编码8081", func(t *testing.T) {
|
||||
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
|
||||
u, _ := start(t, plat)
|
||||
if strings.Contains(u, "127.0.0.1:8081") && plat != "trae" {
|
||||
t.Fatalf("%s 不应硬编码 8081: %s", plat, u)
|
||||
}
|
||||
if strings.Contains(u, "{port}") {
|
||||
t.Fatalf("%s 的 {port} 占位未被替换: %s", plat, u)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
111
internal/core/channels_autoclaim_cover_test.go
Normal file
111
internal/core/channels_autoclaim_cover_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 自动领取的「今天已处理」记账(照参考 coversToday / shouldMarkToday)。
|
||||
//
|
||||
// 真实缺陷(参考 2026-10-02 定位,每天静默漏 100 积分):Qoder 的活动
|
||||
// **每日 10:00(UTC+8)才刷新**,而自动签到在上午跑 —— 上午看到的是**昨天**
|
||||
// 那条 CLAIMED,返回 `already-claimed`。若拿它记账,当天 10 点刷新后就再也
|
||||
// 不去领。
|
||||
//
|
||||
// 修法不靠「给每个渠道配刷新时刻表」(必然漂移),而是让**产出方自己回答**
|
||||
// 「我这条结果覆盖的是不是今天这一轮」(covers_today),记账方只数
|
||||
// 「有几条能证明今天已被处理」。
|
||||
func TestAutoClaimCountsCoversTodayNotKind(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// ① Qoder 在刷新前必须声明 covers_today=false(否则这条链就白修)
|
||||
out, err := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
|
||||
"refreshed": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j == nil || j["kind"] == nil {
|
||||
t.Fatalf("探针应返回 kind,实得 %#v", out)
|
||||
}
|
||||
if j["covers_today"] != false {
|
||||
t.Errorf("刷新前必须声明 covers_today=false(这正是漏领的根因),实得 %v", j["covers_today"])
|
||||
}
|
||||
if j["kind"] == "already-claimed" {
|
||||
t.Error("刷新前**绝不能**报 already-claimed(那会被记账成「今天已处理」)")
|
||||
}
|
||||
|
||||
// ② 刷新后:正常报已领,且 covers_today=true
|
||||
out2, _ := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
|
||||
"refreshed": true,
|
||||
})
|
||||
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j2["covers_today"] != true {
|
||||
t.Errorf("刷新后应声明 covers_today=true,实得 %v", j2["covers_today"])
|
||||
}
|
||||
|
||||
// ③ 记账判据必须**只看 covers_today**,不能用 kind 反推
|
||||
//(`already-claimed` 天然有歧义 —— 可能就是昨天那一轮的痕迹)
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
cov interface{}
|
||||
want bool
|
||||
}{
|
||||
{"刷新前的 already-claimed 不得算已处理", "already-claimed", false, false},
|
||||
{"刷新后的 already-claimed 算已处理", "already-claimed", true, true},
|
||||
{"success 算已处理", "success", true, true},
|
||||
{"covers_today 缺省为 true", "claimed", nil, true},
|
||||
{"inactive 不算已处理", "inactive", true, false},
|
||||
{"failed 不算已处理", "failed", true, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
o, err := ps.Call("channels", "__claim_covers_probe", map[string]interface{}{
|
||||
"kind": c.kind, "covers_today": c.cov,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
oj, _ := o.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if got, _ := oj["covers"].(bool); got != c.want {
|
||||
t.Errorf("%s: 应 %v,实得 %v", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调度频率:一天只跑一次会让「刷新时刻晚于执行时刻」的平台整天漏领。
|
||||
func TestAutoClaimScheduledMoreThanOnceDaily(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
out, err := ps.Call("channels", "__schedule_probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
every, _ := j["auto_claim_every"].(float64)
|
||||
if every <= 0 {
|
||||
t.Fatalf("找不到 auto_claim 的调度间隔,实得 %v", j)
|
||||
}
|
||||
// 必须显著小于一天:活动刷新时刻各平台不同(Qoder 是每日 10:00 UTC+8),
|
||||
// 一天只跑一次就必然有平台漏领。
|
||||
if every >= 86400 {
|
||||
t.Errorf("auto_claim 间隔应远小于一天(否则刷新晚的平台整天漏领),实得 %.0f 秒", every)
|
||||
}
|
||||
if j["first_due_now"] != true {
|
||||
t.Error("auto_claim 应 first_due_now(重启立即补跑,不等一个周期)")
|
||||
}
|
||||
// 已覆盖今天的平台必须靠记账跳过,否则会重复打上游
|
||||
if !strings.Contains(toString(j["has_coverage_key"]), "true") {
|
||||
t.Error("需要「今天已覆盖」的持久化记账键")
|
||||
}
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
if b, ok := v.(bool); ok {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
159
internal/core/channels_autoclaim_test.go
Normal file
159
internal/core/channels_autoclaim_test.go
Normal file
@ -0,0 +1,159 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 自动签到的**能力表**必须与参考真值一致 —— 这张表是 auto_claim 的唯一依据,
|
||||
// 错了就是"静默少领"或"每次调用都报自相矛盾的错"。
|
||||
//
|
||||
// 实测踩到的三处偏差(本轮修正):
|
||||
//
|
||||
// buddy 我们写 claim=true,但 buddy 既无 checkin 又无 claim_fn →
|
||||
// 每次调用返回"不支持每日签到(平台未声明 checkin)"(表说支持、
|
||||
// 函数说不支持)。参考里 buddy **没有** claim。
|
||||
// raccoon 我们声明空表 → 白丢一个可领渠道。参考有:桌面端登录奖励
|
||||
// POST /api/web/desktop/v1/login/points/grant(每号一次)。
|
||||
// zcode 有签到,但每次领取现场产阿里云 captcha(headful Chromium,
|
||||
// 按设备限流 150 次/小时)→ 参考把它列进 auto-checkin 的**排除表**。
|
||||
// 自动跳过,手动仍可领。
|
||||
func TestAutoClaimCapabilityTableMatchesReference(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
_ = c
|
||||
|
||||
// 参考真值(逐个 grep 参考仓的 claim 导出确认)
|
||||
wantClaim := map[string]bool{
|
||||
"trae": true, "minimax": true, "lobsterai": true, "qoder": true,
|
||||
"loomy": true, "raccoon": true, "codearts": true,
|
||||
"cline": false, "gemini": false, "zcode": true, "buddy": false,
|
||||
}
|
||||
// 自动签到必须跳过的(领取有代价)
|
||||
wantAutoExclude := map[string]bool{"zcode": true}
|
||||
|
||||
for plat, want := range wantClaim {
|
||||
t.Run("claim_"+plat, func(t *testing.T) {
|
||||
got := creditsCapability(t, ps, plat)
|
||||
if got == nil {
|
||||
t.Fatalf("%s 无能力声明", plat)
|
||||
}
|
||||
if has := got["claim"] == true; has != want {
|
||||
t.Errorf("%s claim = %v,参考为 %v", plat, has, want)
|
||||
}
|
||||
if got, ok := got["auto_exclude"].(bool); ok != wantAutoExclude[plat] {
|
||||
t.Errorf("%s auto_exclude = %v,应为 %v", plat, got, wantAutoExclude[plat])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 关键一致性:声称 claim=true 的平台**必须**真的可领
|
||||
//(有 checkin 表或有 claim_fn)—— 否则就是 buddy 那种自相矛盾。
|
||||
for plat, want := range wantClaim {
|
||||
if !want {
|
||||
continue
|
||||
}
|
||||
m, err := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": plat})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", plat, err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil {
|
||||
t.Fatalf("%s 无返回", plat)
|
||||
}
|
||||
if j["capable"] != true {
|
||||
t.Errorf("%s 声称 claim=true 但不可领(%v)—— 会出现「表说支持、"+
|
||||
"函数说不支持」的自相矛盾", plat, j["reason"])
|
||||
}
|
||||
}
|
||||
// 反向:不声称 claim 的平台必须被判为不可领
|
||||
m, _ := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": "buddy"})
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j != nil && j["capable"] == true {
|
||||
t.Error("buddy 不该被判为可领(它没有 claim)")
|
||||
}
|
||||
}
|
||||
|
||||
// 自动签到的**调度配置**必须覆盖两项需求:领取 + 模型获取。
|
||||
//
|
||||
// auto_claim 走调度器(不是 HTTP export),故断言调度声明。
|
||||
func TestAutoClaimScheduledWithModelRefresh(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []struct{ name, needle string }{
|
||||
{"每日领取", `fn = "auto_claim"`},
|
||||
{"模型目录自动获取", `fn = "refresh_models"`},
|
||||
{"凭据自动续期", `fn = "refresh_creds"`},
|
||||
{"启动即补跑", `first_due_now = true`},
|
||||
} {
|
||||
if !strings.Contains(b, want.needle) {
|
||||
t.Errorf("调度表缺 %s(%s)", want.name, want.needle)
|
||||
}
|
||||
}
|
||||
// 模型刷新间隔照参考 MODEL_REFRESH_INTERVAL_MS(2 小时)
|
||||
if !strings.Contains(b, `fn = "refresh_models", every = 7200`) {
|
||||
t.Error("refresh_models 间隔应为 7200(参考 2h)")
|
||||
}
|
||||
// 按**行**判断(固定字符窗会溢到下一行,误报)
|
||||
lineOf := func(fn string) string {
|
||||
for _, ln := range strings.Split(b, "\n") {
|
||||
if strings.Contains(ln, `fn = "`+fn+`"`) {
|
||||
return ln
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// auto_claim **现在**也 first_due_now,且间隔远小于一天。
|
||||
//
|
||||
// ★ 这里改过一次,原因值得记下:原判据写的是「auto_claim 是每日任务,
|
||||
// 不该 first_due_now(重启就重领是错的)」—— 那个假设**本身是缺陷的一部分**。
|
||||
// 活动刷新时刻**各平台不同**(Qoder 是每日 10:00 UTC+8),一天只跑一次
|
||||
// 必然有平台整天漏领(参考实测:每天静默漏 100 积分)。修法有两半:
|
||||
// ① 频率提高 + first_due_now(任何时刻重启都能补上);
|
||||
// ② 「今天已覆盖」靠 covers_today 记账跳过(**这才是防重领的正确机制**,
|
||||
// 而不是靠"一天只跑一次"来碰巧避开)。
|
||||
// 只做 ② 不做 ① 仍会漏领;只做 ① 不做 ② 会重复打上游。
|
||||
if ln := lineOf("auto_claim"); !strings.Contains(ln, "first_due_now = true") {
|
||||
t.Errorf("auto_claim 应 first_due_now(否则重启后当天漏领):%s", ln)
|
||||
}
|
||||
// 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈)
|
||||
for _, fn := range []string{"refresh_models", "refresh_creds"} {
|
||||
ln := lineOf(fn)
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(ln, "first_due_now = true") {
|
||||
t.Errorf("%s 应声明 first_due_now(参考点名的重启自愈缺陷):%s", fn, ln)
|
||||
}
|
||||
}
|
||||
// auto_exclude 必须挡住 zcode(领取有代价:headful Chromium)
|
||||
if !strings.Contains(b, "auto_exclude = true") {
|
||||
t.Error("zcode 应标记 auto_exclude(照参考 isAutoCheckinExcluded)")
|
||||
}
|
||||
if i := strings.Index(b, `if cap.auto_exclude then`); i < 0 {
|
||||
t.Error("auto_claim 必须尊重 auto_exclude(否则会拉起浏览器)")
|
||||
}
|
||||
}
|
||||
|
||||
// creditsCapability 读插件的能力表条目。
|
||||
func creditsCapability(t *testing.T, ps interface {
|
||||
Call(string, string, map[string]interface{}) (interface{}, error)
|
||||
}, plat string) map[string]interface{} {
|
||||
t.Helper()
|
||||
m, err := ps.Call("channels", "credits_capability", map[string]interface{}{"platform": plat})
|
||||
if err != nil {
|
||||
t.Fatalf("credits_capability(%s): %v", plat, err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil {
|
||||
return nil
|
||||
}
|
||||
if cap, ok := j["capability"].(map[string]interface{}); ok {
|
||||
return cap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
122
internal/core/channels_balance_tier_test.go
Normal file
122
internal/core/channels_balance_tier_test.go
Normal file
@ -0,0 +1,122 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)。
|
||||
//
|
||||
// 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
|
||||
// 而**服务端扣哪个包不由我们决定** —— 插件能决定的是**用哪个号**:
|
||||
// 优先用「还有 15 天内到期积分」的号(那部分不用就作废),只剩永久积分的号排最后;
|
||||
// **锁定永久积分**时只剩永久积分的号等同于不可用。
|
||||
//
|
||||
// 判据用假余额上游,逐条钉住分档规则(含三个易错点)。
|
||||
func TestBalanceTierSelection(t *testing.T) {
|
||||
// 假 buddy 余额端点:返回三个包,覆盖三种情形
|
||||
// pkg A: 有效 + 10 天内到期 + 剩余 100 → 临时桶
|
||||
// pkg B: 有效 + 到期时间未知 + 剩余 50 → 永久桶(保守)
|
||||
// pkg C: **已失效** + 剩余 9999 → 必须跳过(否则虚增可用额度)
|
||||
future := nowMs() + 300*24*3600*1000 // 300 天后
|
||||
soon := nowMs() + 10*24*3600*1000 // 10 天后
|
||||
body := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"Response": map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Accounts": []map[string]interface{}{
|
||||
{"Status": 1, "CycleCapacityRemain": 100, "DeductionEndTime": soon},
|
||||
{"Status": 1, "CycleCapacityRemain": 50, "DeductionEndTime": future},
|
||||
{"Status": 2, "CycleCapacityRemain": 9999, "DeductionEndTime": future},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(raw)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
// 把一个 buddy 账号加进池,并把余额端点指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"balance": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "tieracct",
|
||||
"credential": `{"access_token":"TOK"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 失败: %#v", m)
|
||||
}
|
||||
|
||||
// ① 分档:临时=100(只有 10 天内那个),永久=50(未知/远期),失效包被跳过
|
||||
out, err := ps.Call("channels", "credits_tier_probe",
|
||||
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
|
||||
if err != nil {
|
||||
t.Fatalf("分档探针: %v", err)
|
||||
}
|
||||
tw, _ := out.(map[string]interface{})
|
||||
tj, _ := tw["json"].(map[string]interface{})
|
||||
if tj == nil {
|
||||
t.Fatalf("无 json: %#v", out)
|
||||
}
|
||||
if tj["error"] != nil {
|
||||
t.Fatalf("分档报错: %v", tj["error"])
|
||||
}
|
||||
if got, _ := tj["expiring"].(float64); got != 100 {
|
||||
t.Errorf("临时余额应为 100(仅 10 天内到期的包),实得 %v", tj["expiring"])
|
||||
}
|
||||
if got, _ := tj["permanent"].(float64); got != 50 {
|
||||
t.Errorf("永久余额应为 50(到期未知/远期),实得 %v(若为 10049 说明失效包没跳过)",
|
||||
tj["permanent"])
|
||||
}
|
||||
|
||||
// ② 档位判定:有临时积分 → "expiring"(最优先)
|
||||
if tj["tier"] != "expiring" {
|
||||
t.Errorf("有 15 天内到期积分时档位应为 expiring,实得 %v", tj["tier"])
|
||||
}
|
||||
|
||||
// ③ 锁定永久积分:该账号**仍可用**(因为有临时积分)
|
||||
if _, err := ps.Call("channels", "__set_lock_permanent_probe",
|
||||
map[string]interface{}{"lock": true}); err != nil {
|
||||
t.Fatalf("锁定探针: %v", err)
|
||||
}
|
||||
out2, _ := ps.Call("channels", "credits_tier_probe",
|
||||
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
|
||||
tj2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if tj2["usable"] != true {
|
||||
t.Errorf("锁定永久积分时,有临时积分的号仍应可用,实得 usable=%v", tj2["usable"])
|
||||
}
|
||||
// ④ 窗口可被显式覆盖:把窗口设成 0 ⇒ 没有"临时积分" ⇒ 只剩永久 ⇒ 锁定后不可用
|
||||
if _, err := ps.Call("channels", "__set_expiring_window_probe",
|
||||
map[string]interface{}{"days": 0}); err != nil {
|
||||
t.Fatalf("窗口探针: %v", err)
|
||||
}
|
||||
out3, _ := ps.Call("channels", "credits_tier_probe",
|
||||
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
|
||||
tj3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if got, _ := tj3["expiring"].(float64); got != 0 {
|
||||
t.Errorf("窗口=0 时临时桶应为 0,实得 %v", tj3["expiring"])
|
||||
}
|
||||
if tj3["usable"] != false {
|
||||
t.Errorf("窗口=0 且锁定永久积分时该号应不可用,实得 usable=%v", tj3["usable"])
|
||||
}
|
||||
}
|
||||
|
||||
// nowMs 返回当前毫秒时间戳(与插件里 DeductionEndTime 的口径一致)。
|
||||
func nowMs() int64 { return time.Now().UnixMilli() }
|
||||
57
internal/core/channels_buddy_catalog_filter_test.go
Normal file
57
internal/core/channels_buddy_catalog_filter_test.go
Normal file
@ -0,0 +1,57 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 远端模型目录混着「不可调用」的条目,必须过滤掉。
|
||||
//
|
||||
// 真实缺陷(生产实测):buddy 源声明了 24 个模型,其中
|
||||
//
|
||||
// nes-gf / codewise-completions / codewise-rewrite / codewise-jump /
|
||||
// codewise-default-model-v2 / hunyuan-image-alpha / auto
|
||||
//
|
||||
// 全部调不通(`glm-5.1` 也是 404)。用户选中就报错,而错误文案指向**模型**,
|
||||
// 极易被误判成"这个模型坏了"。
|
||||
//
|
||||
// 参考实现(buddy.ts 的 isChatModel / isAutoSelectAlias)记录了**四类**判据,
|
||||
// 且都是实测依据:
|
||||
//
|
||||
// ① 自动选择别名 auto/default(**只按字面量**,前缀匹配会误伤 default-model)
|
||||
// ② 补全/NES 专用前缀 nes- / completion- / codewise-,或 supportsExtra=true
|
||||
// (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
|
||||
// ③ maxOutputTokens >0 且 ≤256(补全用途;对话模型普遍 ≥24000)
|
||||
// ④ 带 text-to-image 标签(如 hunyuan-image-alpha)
|
||||
func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 判据一:四类规则必须都在源码里(防止有人"精简"掉其中一条)
|
||||
for _, rule := range []string{
|
||||
`id == "auto" or id == "default"`, // ①
|
||||
`id:sub(1, 4) == "nes-"`, // ②
|
||||
`id:sub(1, 11) == "completion-"`, // ②
|
||||
`id:sub(1, 9) == "codewise-"`, // ②
|
||||
`m.supportsExtra == true`, // ②
|
||||
`mo > 0 and mo <= 256`, // ③
|
||||
`t == "text-to-image"`, // ④
|
||||
} {
|
||||
if !strings.Contains(s, rule) {
|
||||
t.Errorf("buddy 目录过滤缺少规则:%s\n"+
|
||||
"(这四类都是参考用真实请求验证过的「列出来就会让用户报错」的条目,"+
|
||||
"删任何一条都会把不可调用模型放回选择器)", rule)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据二:`default-model` / `fast-model` 必须**不在**被过滤的形态里。
|
||||
// 参考注释明确警告:只过滤 auto/default 两个字面量,不做前缀匹配 ——
|
||||
// 前缀会误伤这两个国际版别名(它们被 craft agent 引用,是官方推荐入口)。
|
||||
if strings.Contains(s, `id:sub(1, 7) == "default"`) {
|
||||
t.Error("buddy 过滤把 default 做成了**前缀**匹配 ⇒ 会误伤 default-model(参考点名警告)")
|
||||
}
|
||||
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
|
||||
}
|
||||
220
internal/core/channels_callback_loopback_test.go
Normal file
220
internal/core/channels_callback_loopback_test.go
Normal file
@ -0,0 +1,220 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 三个 callback 平台的回调路径**各是注册值**,且校验规则不同:
|
||||
//
|
||||
// Trae /authorize 不带 state(token 直接放 query 里)
|
||||
// 有道 /auth/callback 必须带 state
|
||||
// Gemini /oauth-callback 必须带 state
|
||||
//
|
||||
// 路径写错 → 上游 redirect_uri_mismatch;校验写错 → 要么接不住回调、
|
||||
// 要么被伪造回调塞入凭据。本测试打真上游形态(本地 loopback 接收器)。
|
||||
func TestCallbackPlatformsLoopback(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := func(t *testing.T, plat string) (m map[string]interface{}, nonce, sid string) {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s start_login: %v", plat, err)
|
||||
}
|
||||
m = out.JSON.(map[string]interface{})
|
||||
s := m["session"].(map[string]interface{})
|
||||
return m, s["nonce"].(string), s["id"].(string)
|
||||
}
|
||||
|
||||
poll := func(t *testing.T, sid string) map[string]interface{} {
|
||||
t.Helper()
|
||||
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return o.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
t.Run("行1 Trae 回调路径是 /authorize 且无 state 也能接住", func(t *testing.T) {
|
||||
_, _, sid := start(t, "trae")
|
||||
// 交换端点指到假上游
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"Token":"ACCESS-NEW"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "trae", "callback": map[string]interface{}{"exchange_url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 重新起会话,拿到带 loopback 的 redirect_uri
|
||||
m, nonce, sid := start(t, "trae")
|
||||
ru, _ := m["redirect_uri"].(string)
|
||||
if !strings.HasSuffix(ru, "/authorize") {
|
||||
t.Fatalf("trae 回调路径必须是 /authorize,实得 %q", ru)
|
||||
}
|
||||
// Trae 的回调**不带 state**,而是把 token 放 query 里
|
||||
q := url.Values{}
|
||||
q.Set("refreshToken", "RT-1")
|
||||
q.Set("userInfo", `{"UserID":"u1","ScreenName":"张三"}`)
|
||||
resp, err := http.Get(ru + "?" + q.Encode())
|
||||
if err != nil {
|
||||
t.Fatalf("打 loopback 接收器失败: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("Trae 回调(无 state)应被接住,实得 %d", resp.StatusCode)
|
||||
}
|
||||
st := waitState(t, poll, sid, "completed")
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("应完成登录,实得 %v", st)
|
||||
}
|
||||
raw, ok, _ := ps.GetSecret("channels", "channels:trae")
|
||||
if !ok {
|
||||
t.Fatal("凭据未落库")
|
||||
}
|
||||
var cred map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cred["access_token"] != "ACCESS-NEW" {
|
||||
t.Fatalf("凭据应是交换后的新 access,实得 %v", cred["access_token"])
|
||||
}
|
||||
if cred["uid"] != "u1" {
|
||||
t.Fatalf("uid 应从 userInfo.UserID 取出(签到设备身份的种子), 实得 %v", cred["uid"])
|
||||
}
|
||||
_ = nonce
|
||||
})
|
||||
|
||||
t.Run("行2 Gemini 路径是 /oauth-callback 且错 state 被拒", func(t *testing.T) {
|
||||
m, nonce, sid := start(t, "gemini")
|
||||
ru, _ := m["redirect_uri"].(string)
|
||||
if !strings.HasSuffix(ru, "/oauth-callback") {
|
||||
t.Fatalf("gemini 回调路径必须是 /oauth-callback(参考 GEMINI_CALLBACK_PATH),实得 %q", ru)
|
||||
}
|
||||
if !strings.HasPrefix(ru, "http://localhost:") {
|
||||
t.Fatalf("gemini 回调主机必须是 localhost(参考注释明确),实得 %q", ru)
|
||||
}
|
||||
// 错 state 必须被接收器拒(400),且不推进会话
|
||||
bad, err := http.Get(ru + "?code=C-1&state=WRONG")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad.Body.Close()
|
||||
if bad.StatusCode != 400 {
|
||||
t.Fatalf("state 不匹配应 400(防伪造回调),实得 %d", bad.StatusCode)
|
||||
}
|
||||
if st := poll(t, sid); st["state"] != "pending" {
|
||||
t.Fatalf("错 state 不应推进会话,实得 %v", st)
|
||||
}
|
||||
// 正确 state → 完成(交换端点指到假上游)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
// 必须带 client_secret:只发 client_id 会 invalid_request,
|
||||
// 症状正是「浏览器显示授权成功但账号不出现」
|
||||
if r.PostFormValue("client_secret") == "" {
|
||||
t.Error("token 交换必须带 client_secret")
|
||||
}
|
||||
if r.PostFormValue("grant_type") != "authorization_code" {
|
||||
t.Errorf("grant_type = %q", r.PostFormValue("grant_type"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token":"GA-1","refresh_token":"GR-1","expires_in":3600}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "gemini",
|
||||
"callback": map[string]interface{}{"token_url": ts.URL, "mode": "code"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good, err := http.Get(ru + "?code=C-1&state=" + nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good.Body.Close()
|
||||
st := waitState(t, poll, sid, "completed")
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("应完成登录,实得 %v", st)
|
||||
}
|
||||
raw, ok, _ := ps.GetSecret("channels", "channels:gemini")
|
||||
if !ok {
|
||||
t.Fatal("凭据未落库")
|
||||
}
|
||||
var cred map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cred["access_token"] != "GA-1" {
|
||||
t.Fatalf("access_token = %v", cred["access_token"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// waitState 轮询到目标状态或超时(loopback 写文件是异步的,需要一拍)。
|
||||
func waitState(t *testing.T, poll func(*testing.T, string) map[string]interface{},
|
||||
sid, want string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var st map[string]interface{}
|
||||
for i := 0; i < 30; i++ {
|
||||
st = poll(t, sid)
|
||||
if st["state"] != "pending" {
|
||||
return st
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// 占位符未替换检查:授权 URL 里残留 {xxx} 会让上游收到字面量占位符
|
||||
// (而且往往不报错,只是行为诡异)。三个 callback 平台逐个验。
|
||||
func TestAuthorizeURLsHaveNoUnfilledPlaceholders(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", plat, err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
for _, key := range []string{"url", "redirect_uri"} {
|
||||
v, _ := m[key].(string)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(v, "{") || strings.Contains(v, "nil") {
|
||||
t.Errorf("%s 的 %s 仍有未替换占位符: %s", plat, key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
internal/core/channels_capability_parse_test.go
Normal file
55
internal/core/channels_capability_parse_test.go
Normal file
@ -0,0 +1,55 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 用**真实的 models.dev 响应**验证解析口径(5.6MB / 229 键 / opencode 122 模型)。
|
||||
//
|
||||
// 为什么必须用真数据:字段路径(modalities.input / cost.input / limit.context)
|
||||
// 是我照参考注释写的,只有真数据能证明写对了。合成样本会把这个验证变成自说自话。
|
||||
//
|
||||
// 样本不在仓库里(5.6MB),缺失时跳过 —— 但 CI 里能拉到就一定会跑。
|
||||
func TestCapabilityParseAgainstRealModelsDev(t *testing.T) {
|
||||
b, err := os.ReadFile("/tmp/modelsdev.json")
|
||||
if err != nil {
|
||||
t.Skip("无 models.dev 样本(/tmp/modelsdev.json),跳过")
|
||||
}
|
||||
_, ps := poolCore(t)
|
||||
out, err := ps.Call("channels", "__parse_capability_probe", map[string]interface{}{
|
||||
"body": string(b), "key": "opencode",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j["error"] != nil {
|
||||
t.Fatalf("解析失败: %v", j["error"])
|
||||
}
|
||||
total, _ := j["total"].(float64)
|
||||
free, _ := j["free"].(float64)
|
||||
image, _ := j["image"].(float64)
|
||||
ctx, _ := j["with_ctx"].(float64)
|
||||
t.Logf("opencode: total=%.0f free=%.0f image=%.0f with_ctx=%.0f", total, free, image, ctx)
|
||||
|
||||
// ① 规模:实测 122 个模型(允许上游增减,给个合理区间)
|
||||
if total < 50 {
|
||||
t.Errorf("解析出的模型数太少(%.0f)—— 字段路径可能没对上真实结构", total)
|
||||
}
|
||||
// ② 免费判定必须有命中的(实测有若干 cost 全 0 的模型)
|
||||
if free == 0 {
|
||||
t.Error("一个免费模型都没识别出来 —— cost 判定路径可能错了")
|
||||
}
|
||||
if free >= total {
|
||||
t.Error("全部被标免费 —— cost 判定过于宽松")
|
||||
}
|
||||
// ③ 多模态也要有命中的(gpt-5.4 之类含 image)
|
||||
if image == 0 {
|
||||
t.Error("没识别出任何多模态模型 —— modalities 路径可能错了")
|
||||
}
|
||||
// ④ 上下文窗口要能取到(limit.context)
|
||||
if ctx == 0 {
|
||||
t.Error("没取到任何上下文窗口 —— limit 路径可能错了")
|
||||
}
|
||||
}
|
||||
86
internal/core/channels_capability_test.go
Normal file
86
internal/core/channels_capability_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 模型能力元数据(照参考 opencode-capability)。
|
||||
//
|
||||
// 关键纪律:`/zen/v1/models` 只返回 4 个字段、**不含能力信息**;能力在 models.dev
|
||||
// (实测 5.05 MB / 下载 1.4s)。参考的真机事故:最初在模型列表渲染路径上直接
|
||||
// 等这个下载,模型选择器要等 1.4s+ 才出列表 —— 用户报障「点开是一片空白」。
|
||||
//
|
||||
// ⇒ 三条硬约定:渲染路径只读同步缓存;磁盘缓存冷启动命中;拉取在后台。
|
||||
// 我们额外:本插件是服务端进程,但 5MB 仍不能放请求路径上 ⇒ 挂定时任务。
|
||||
//
|
||||
// 本判据钉住"非阻塞"这一条:**缓存未就绪时 model_list 必须立刻返回**,
|
||||
// 而不是去等网络。
|
||||
func TestCapabilityReadNeverBlocks(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
// 清掉能力缓存(模拟冷启动)
|
||||
if _, err := ps.Call("channels", "__clear_capabilities_probe", nil); err != nil {
|
||||
t.Fatalf("清缓存: %v", err)
|
||||
}
|
||||
// 冷启动下取模型列表:必须成功返回(退回兜底 isFree),且不得等待网络。
|
||||
// 判据:能返回就说明没阻塞(真去下 5MB 会超时/极慢)。
|
||||
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
|
||||
if err != nil {
|
||||
t.Fatalf("model_list: %v", err)
|
||||
}
|
||||
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j == nil || j["ok"] != true {
|
||||
t.Fatalf("冷启动下 model_list 必须成功(退回兜底),实得 %#v", m)
|
||||
}
|
||||
models := asArrInterface(j["models"])
|
||||
if len(models) == 0 {
|
||||
t.Fatal("冷启动下仍应列出兜底模型")
|
||||
}
|
||||
// 冷启动时 isFree 应退回声明的 models_free(不能凭空说全部免费)
|
||||
sawFree := false
|
||||
for _, it := range models {
|
||||
if mm, ok := it.(map[string]interface{}); ok && mm["isFree"] == true {
|
||||
sawFree = true
|
||||
}
|
||||
}
|
||||
if !sawFree {
|
||||
t.Error("冷启动时 opencode 应有 free 模型(退回声明的 models_free)")
|
||||
}
|
||||
}
|
||||
|
||||
// 能力元数据一旦就绪,isFree 必须改由它决定(cost 权威,而非快照)。
|
||||
func TestCapabilityOverridesFreeFlag(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
// 注入一份假能力:把某个原本标 free 的模型改成收费、把非 free 的改成免费
|
||||
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
|
||||
"platform": "opencode",
|
||||
"model_id": "big-pickle",
|
||||
"is_free": false, // 原本声明 free,能力表说收费 ⇒ 必须以能力表为准
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
|
||||
"platform": "opencode",
|
||||
"model_id": "glm-5.2",
|
||||
"is_free": true, // 原本不在 free 列表,能力表说免费
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
m, _ := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
|
||||
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
|
||||
got := map[string]bool{}
|
||||
for _, it := range asArrInterface(j["models"]) {
|
||||
if mm, ok := it.(map[string]interface{}); ok {
|
||||
if id, ok := mm["id"].(string); ok {
|
||||
if f, ok := mm["isFree"].(bool); ok {
|
||||
got[id] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if got["big-pickle"] {
|
||||
t.Error("能力表说收费时 isFree 必须为 false(权威来源是 cost,不是快照)")
|
||||
}
|
||||
if !got["glm-5.2"] {
|
||||
t.Error("能力表说免费时 isFree 必须为 true")
|
||||
}
|
||||
}
|
||||
235
internal/core/channels_clear_credentialless_test.go
Normal file
235
internal/core/channels_clear_credentialless_test.go
Normal file
@ -0,0 +1,235 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。
|
||||
//
|
||||
// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、
|
||||
// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数,
|
||||
// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用
|
||||
// (codearts 10 + trae 2 全是占位)。
|
||||
//
|
||||
// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。
|
||||
func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
// ① 默认 dry_run:必须**不删**任何东西
|
||||
pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preJ := jsonOf(t, pre)
|
||||
if preJ["dry_run"] != true {
|
||||
t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"])
|
||||
}
|
||||
// 夹具放了 4 个 codearts(3 空壳 + 1 有凭据)
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||||
t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got)
|
||||
}
|
||||
// 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5
|
||||
if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 {
|
||||
t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"])
|
||||
}
|
||||
if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 {
|
||||
t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"])
|
||||
}
|
||||
|
||||
// ② 真执行:删无凭据的,**保留有凭据的**
|
||||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||||
map[string]interface{}{"dry_run": false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got)
|
||||
}
|
||||
// ★ 关键:留下的必须是**有凭据的那个**
|
||||
left := accountIDs(t, h, "codearts")
|
||||
if len(left) != 1 || left[0] != "withcred" {
|
||||
t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left)
|
||||
}
|
||||
// 有凭据的账号其凭据也必须仍在(不能被误删)
|
||||
if !h.hasCredential("withcred") {
|
||||
t.Fatal("有凭据账号的凭据被误删了")
|
||||
}
|
||||
t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好")
|
||||
}
|
||||
|
||||
// 指定平台时只动那个平台,别的不碰。
|
||||
func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||||
map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// codearts 被清(剩 1),trae 的 2 个占位**不该被动**
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("codearts 应剩 1,实得 %d", got)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "trae")); got != 2 {
|
||||
t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got)
|
||||
}
|
||||
t.Log("✓ 平台范围隔离正确")
|
||||
}
|
||||
|
||||
// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。
|
||||
func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
res, err := h.ps.Call("channels", "account_clear_credentialless",
|
||||
map[string]interface{}{"platform": "no-such-platform", "dry_run": false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
if j["error"] == nil {
|
||||
t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j)
|
||||
}
|
||||
// 且什么都没删
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||||
t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type accountCleanupHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &accountCleanupHarness{t: t, ps: ps}
|
||||
// 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位
|
||||
for _, id := range []string{"empty1", "empty2", "empty3"} {
|
||||
h.add("codearts", id, "")
|
||||
}
|
||||
h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`)
|
||||
h.add("trae", "tempty1", "")
|
||||
h.add("trae", "tempty2", "")
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *accountCleanupHarness) add(platform, id, cred string) {
|
||||
h.t.Helper()
|
||||
args := map[string]interface{}{"platform": platform, "account_id": id}
|
||||
if cred != "" {
|
||||
args["credential"] = cred
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "account_add", args); err != nil {
|
||||
h.t.Fatalf("account_add(%s/%s): %v", platform, id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *accountCleanupHarness) hasCredential(accountID string) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list",
|
||||
map[string]interface{}{"platform": "codearts"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
for _, a := range accountsOf(h.t, res) {
|
||||
if a["id"] == accountID {
|
||||
return a["has_credential"] == true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。
|
||||
func jsonOf(t *testing.T, got interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("返回值不是对象: %#v", got)
|
||||
}
|
||||
j, ok := m["json"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("返回值缺 json 层: %#v", m)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// accountsOf 取 account_list 返回的账号数组。
|
||||
func accountsOf(t *testing.T, got interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
j := jsonOf(t, got)
|
||||
arr, _ := j["accounts"].([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accountIDs 列某平台的账号 id。
|
||||
func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string {
|
||||
t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list",
|
||||
map[string]interface{}{"platform": platform})
|
||||
if err != nil {
|
||||
t.Fatalf("account_list(%s): %v", platform, err)
|
||||
}
|
||||
var out []string
|
||||
for _, a := range accountsOf(t, res) {
|
||||
if id, ok := a["id"].(string); ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。
|
||||
//
|
||||
// 反例(实测踩到,我自己写的):照其它 handler 写
|
||||
//
|
||||
// `if args.platform == nil then args = req end`
|
||||
//
|
||||
// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成
|
||||
// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成
|
||||
// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。
|
||||
//
|
||||
// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。
|
||||
func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
// 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态)
|
||||
res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless",
|
||||
&lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != 200 {
|
||||
t.Fatalf("status=%d body=%v", res.Status, res.Body)
|
||||
}
|
||||
j, _ := res.JSON.(map[string]interface{})
|
||||
if j["dry_run"] == true {
|
||||
t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j)
|
||||
}
|
||||
if j["deleted"] == nil {
|
||||
t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j)
|
||||
}
|
||||
// 全平台的 5 个空壳都被删(codearts 3 + trae 2)
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "trae")); got != 0 {
|
||||
t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got)
|
||||
}
|
||||
t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"])
|
||||
}
|
||||
91
internal/core/channels_cline_adapter_test.go
Normal file
91
internal/core/channels_cline_adapter_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cline 的 429/402 话术与冷却时长(照参考 cline-rate-limit.ts)。
|
||||
//
|
||||
// ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",用户看到
|
||||
// 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就从当下重新计一小时
|
||||
// ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
|
||||
// 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游。
|
||||
//
|
||||
// 抓包真值(参考实测,no-retry: true —— 没有任何 retry-after 头):
|
||||
//
|
||||
// HTTP 429
|
||||
// {"error":{"code":"INFERENCE_CAP_ERROR",
|
||||
// "message":"Error 429: Daily free limit reached on model
|
||||
// deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
|
||||
func TestClineAdapterParsesProseWait(t *testing.T) {
|
||||
c, _ := poolCore(t)
|
||||
vm := c.VM()
|
||||
|
||||
// ① 当日免费额度用尽:必须被归一成**内核已认的中立短语**
|
||||
//(否则内核按 30 秒短冷却处理 ⇒ 每 30 秒猛敲上游)
|
||||
body := `{"error":{"code":"INFERENCE_CAP_ERROR","message":` +
|
||||
`"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 19h 39m"}}`
|
||||
out, ok, err := vm.TransformError("channels-cline", 429, body)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("transform_error: err=%v ok=%v", err, ok)
|
||||
}
|
||||
if !strings.Contains(out, "额度已用完") {
|
||||
t.Errorf("必须归一成内核已认的「额度已用完」(否则不会被当成额度耗尽处理),实得 %q", out)
|
||||
}
|
||||
// 真实等待时长必须出现在话术里(19h39m ≈ 19 小时)
|
||||
if !strings.Contains(out, "19") {
|
||||
t.Errorf("必须把真实等待时长写进话术(用户才知道要等多久),实得 %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "重试无用") {
|
||||
t.Errorf("当日额度用尽要明确告知重试无用(否则用户以为等一会就好),实得 %q", out)
|
||||
}
|
||||
// ⚠️ 绝不能出现参考踩过的「1 小时」误导
|
||||
if strings.Contains(out, "1 小时") && !strings.Contains(out, "19") {
|
||||
t.Errorf("不得退回快照式的「1 小时」误导,实得 %q", out)
|
||||
}
|
||||
|
||||
// ② 时长 token 必须**累加**(`1h 30m` = 90 分钟),不能只取第一个 ——
|
||||
// 通过真实的 transform_error 路径验证(不自己重算解析器)。
|
||||
acc, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Daily free limit reached. Try again in 1h 30m"}}`)
|
||||
if !strings.Contains(acc, "1 小时 30 分") {
|
||||
t.Errorf("`1h 30m` 必须累加成人话时长(1 小时 30 分),实得 %q", acc)
|
||||
}
|
||||
// 秒级也要能解析(否则秒级限流会退化成通用文案)
|
||||
secs, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Rate limited. Try again in 45s"}}`)
|
||||
if !strings.Contains(secs, "分") && !strings.Contains(secs, "秒") {
|
||||
t.Errorf("秒级时长应被解析并说出,实得 %q", secs)
|
||||
}
|
||||
|
||||
// ③ 402:等待不会恢复,必须说清楚要充值(不能只说"限流")
|
||||
out402, _, _ := vm.TransformError("channels-cline", 402,
|
||||
`{"error":{"message":"Insufficient credits"}}`)
|
||||
if !strings.Contains(out402, "充值") {
|
||||
t.Errorf("402 应明确提示需充值(等待无用),实得 %q", out402)
|
||||
}
|
||||
|
||||
// ④ 普通 429 带时长:如实说,但不谎报成额度耗尽
|
||||
out429, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Rate limited. Try again in 30s"}}`)
|
||||
if strings.Contains(out429, "额度已用完") {
|
||||
t.Errorf("普通限流不得谎报成额度耗尽(那会让内核长时间冷却一个健康源),实得 %q", out429)
|
||||
}
|
||||
if !strings.Contains(out429, "限流") {
|
||||
t.Errorf("普通 429 应说成限流,实得 %q", out429)
|
||||
}
|
||||
|
||||
// ⑤ 多种外壳都要认(`{error:"…"}` 字符串 / `{msg}` / 非 JSON 纯文本)
|
||||
//(只认一种会在别的形态下静默丢掉整条信息)
|
||||
for _, shell := range []string{
|
||||
`{"error":"Daily free limit reached. Try again in 1h"}`,
|
||||
`{"msg":"Daily free limit reached. Try again in 1h"}`,
|
||||
`Daily free limit reached. Try again in 1h`,
|
||||
} {
|
||||
o, _, _ := vm.TransformError("channels-cline", 429, shell)
|
||||
if !strings.Contains(o, "额度已用完") {
|
||||
t.Errorf("外壳 %q 未被识别为额度耗尽,实得 %q", shell, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
171
internal/core/channels_codearts_adapter_test.go
Normal file
171
internal/core/channels_codearts_adapter_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CodeArts 专用适配器是通过**插件自装**(__llmsproxy_adapters.install)
|
||||
// 引入的,不是内置适配器 —— 因为 AK/SK 签名属厂商知识,内置里出现 codearts
|
||||
// 就破了"内核零厂商名"纪律。
|
||||
//
|
||||
// 这组判据要证明三件事:
|
||||
// 1. 插件加载时确实把适配器装上了
|
||||
// 2. 装上之后,源能引用它(不是"装了但引用不到")
|
||||
// 3. 它产出的签名是**真的能验**(而不是拼出个像签名的字符串)
|
||||
func TestCodeArtsAdapterInstalledByPlugin(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 适配器必须在插件加载后就位
|
||||
adapters := c.ListAdapters()
|
||||
found := false
|
||||
for _, a := range adapters {
|
||||
if a.Name == "codearts" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("插件未装 codearts 适配器;已装: %#v", adapters)
|
||||
}
|
||||
|
||||
// ② 归属必须是 channels 插件(证明是它装的,不是内置)
|
||||
if owner := ps.AdapterOwnerOf("codearts"); owner != "channels" {
|
||||
t.Errorf("适配器归属应为 channels,实得 %q", owner)
|
||||
}
|
||||
|
||||
// ③ 签名必须真的可验:用 Go 标准库按 SDK-HMAC-SHA256 独立复算
|
||||
got, err := ps.Call("channels", "__codearts_adapter_sign_probe", map[string]interface{}{
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/api/v2/chat/completions",
|
||||
"method": "POST",
|
||||
"body": `{"model":"CodeArts-Pangu"}`,
|
||||
"api_key": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST","security_token":"STTEST"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("签名探针: %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("探针无 json: %#v", got)
|
||||
}
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("探针报错: %v", m["error"])
|
||||
}
|
||||
auth, _ := m["authorization"].(string)
|
||||
if !strings.HasPrefix(auth, "SDK-HMAC-SHA256 Access=AKTEST,SignedHeaders=") {
|
||||
t.Fatalf("Authorization 形态不符(参考要求 Access= 不是 Credential=): %s", auth)
|
||||
}
|
||||
if !strings.Contains(auth, "Signature=") {
|
||||
t.Fatalf("Authorization 缺 Signature: %s", auth)
|
||||
}
|
||||
// 签名后追加的头必须在,且不参与 canonical
|
||||
for _, k := range []string{"Agent-Type", "X-Language"} {
|
||||
if m["hdr_"+k] == nil || m["hdr_"+k] == "" {
|
||||
t.Errorf("缺签名后追加的头 %s", k)
|
||||
}
|
||||
}
|
||||
if m["hdr_Agent-Type"] != "PromptCenter" {
|
||||
t.Errorf("Agent-Type = %v,应为 PromptCenter", m["hdr_Agent-Type"])
|
||||
}
|
||||
// canonical 不得包含签名后追加的头(参考注释点名:它们是签名后加的)
|
||||
canon, _ := m["canonical"].(string)
|
||||
if strings.Contains(canon, "agent-type") || strings.Contains(canon, "x-language") {
|
||||
t.Errorf("canonical 不应含签名后追加的头:\n%s", canon)
|
||||
}
|
||||
// Authorization 不得进 canonical
|
||||
if strings.Contains(canon, "authorization") {
|
||||
t.Errorf("canonical 不应含 authorization:\n%s", canon)
|
||||
}
|
||||
// x-sdk-content-sha256 说明 payload hash 参与了签名
|
||||
if !strings.Contains(canon, "x-sdk-content-sha256") {
|
||||
t.Errorf("canonical 应含 x-sdk-content-sha256:\n%s", canon)
|
||||
}
|
||||
// URI 必须以 / 结尾(参考点名的易错点)
|
||||
if !strings.Contains(canon, "/api/v2/chat/completions/\n") {
|
||||
t.Errorf("canonical 的 URI 未补尾斜杠(参考点名的易错点):\n%s", canon)
|
||||
}
|
||||
// ④ **独立验签**:用 Go 标准库按 SDK-HMAC-SHA256 从 canonical 复算签名,
|
||||
// 必须与适配器给出的 Signature 逐字节相同。
|
||||
//
|
||||
// 为什么必须有这一步:前面几条只验证"形态正确"(Access=、SignedHeaders、
|
||||
// 尾斜杠)。签名算法本身写错(HMAC 输入少一段、canonical 分隔符用错)时
|
||||
// 形态依然全对,但上游会 401。复算才是真正的判据。
|
||||
sts, _ := m["string_to_sign"].(string)
|
||||
if sts == "" {
|
||||
t.Fatal("探针未带出 string_to_sign(判据必须比对真实函数算出的中间值)")
|
||||
}
|
||||
// string_to_sign = "SDK-HMAC-SHA256\n" + date + "\n" + sha256(canonical)
|
||||
parts := strings.SplitN(sts, "\n", 3)
|
||||
if len(parts) != 3 || parts[0] != "SDK-HMAC-SHA256" {
|
||||
t.Fatalf("string_to_sign 头段不符: %q", sts)
|
||||
}
|
||||
canonSum := sha256.Sum256([]byte(canon))
|
||||
if parts[2] != hex.EncodeToString(canonSum[:]) {
|
||||
t.Errorf("string_to_sign 的 canonical 摘要不符\n算得: %s\n实得: %s",
|
||||
hex.EncodeToString(canonSum[:]), parts[2])
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte("SKTEST"))
|
||||
mac.Write([]byte(sts))
|
||||
wantSig := hex.EncodeToString(mac.Sum(nil))
|
||||
gotSig := auth[strings.Index(auth, "Signature=")+len("Signature="):]
|
||||
if gotSig != wantSig {
|
||||
t.Errorf("签名独立复算不符(算法写错?)\n复算: %s\n实得: %s", wantSig, gotSig)
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(m)
|
||||
t.Logf("签名探针输出: %s", string(b))
|
||||
}
|
||||
|
||||
// 源注册必须真的引用插件装的 codearts 适配器 —— "装了但源还指向 openai"
|
||||
// 是这类改动最可能的漏配,且不会报错(只是签名不生效、上游 401)。
|
||||
func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
|
||||
// ⚠️ 必须用 poolCore:它给 channels 授予了 add_source/list_sources 等
|
||||
// 内核权限。用 kernelTestCore 裸加载插件时 add_source 会被拒
|
||||
//("kernel action add_source is not granted to plugin channels"),
|
||||
// 而这个拒绝**不报错、只是不出源** —— 我就在这里绕了一圈才看出根因。
|
||||
c, ps := poolCore(t)
|
||||
// 建一个 codearts 账号 → 触发源注册
|
||||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 报错: %#v", m)
|
||||
}
|
||||
aid, _ := j["account_id"].(string)
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
if !ok {
|
||||
names := []string{}
|
||||
for _, x := range srcs {
|
||||
if n, _ := x["name"].(string); n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
t.Fatalf("codearts 账号 %q 应已同步源;现有源: %v", aid, names)
|
||||
}
|
||||
if ad, _ := sm["adapter"].(string); ad != "codearts" {
|
||||
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)
|
||||
}
|
||||
bu, _ := sm["base_url"].(string)
|
||||
if !strings.Contains(bu, "snap-access") {
|
||||
t.Errorf("base_url 不符: %q", bu)
|
||||
}
|
||||
}
|
||||
174
internal/core/channels_codearts_catalog_test.go
Normal file
174
internal/core/channels_codearts_catalog_test.go
Normal file
@ -0,0 +1,174 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ codearts 的模型目录必须从**两个**签名端点拉取并合并。
|
||||
//
|
||||
// 真实缺口(本轮发现):codearts 有 10 个可用账号,但目录里只有硬编码的
|
||||
// 1 个模型 "CodeArts-Pangu"。参考实现(fetchCodeArtsRemoteModels)打的是:
|
||||
//
|
||||
// ① opengw gateway/config → result.models(benefit 模型)
|
||||
// ② snap-access /v1/model/builtin → builtinModels(常规模型)
|
||||
//
|
||||
// 两者都**必须 AK/SK 签名**,而我们的 refreshRemoteModels 此前只支持 Bearer
|
||||
// ⇒ 这两个端点从来没有被打过。
|
||||
//
|
||||
// 判据看**两个端点的真实命中**(各自拉到自己的字段名),而不是"目录非空"
|
||||
// —— 后者只要兜底表在就恒真。
|
||||
type codeartsCatalogHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
mu sync.Mutex
|
||||
hits map[int]int // 端点序号 → 命中次数
|
||||
gateway string
|
||||
builtin string
|
||||
authSeen []string
|
||||
}
|
||||
|
||||
func newCodeartsCatalogHarness(t *testing.T) *codeartsCatalogHarness {
|
||||
t.Helper()
|
||||
h := &codeartsCatalogHarness{t: t, hits: map[int]int{}}
|
||||
|
||||
mk := func(idx int, body string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
h.hits[idx]++
|
||||
// ★ 必须看到 AK/SK 签名的三个特征头,否则等于没签名
|
||||
authSeen := []string{
|
||||
r.Header.Get("X-Sdk-Date"),
|
||||
r.Header.Get("X-Sdk-Content-Sha256"),
|
||||
r.Header.Get("Authorization"),
|
||||
}
|
||||
h.authSeen = append(h.authSeen, strings.Join(authSeen, "|"))
|
||||
h.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
}
|
||||
// ① gateway:benefit 模型在 result.models
|
||||
gw := mk(0, `{"result":{"models":[{"model_id":"glm-5.3-flash"},{"model_id":"deepseek-v4-flash"}]}}`)
|
||||
// ② snap builtin:常规模型在 builtinModels(**对象**,键为序号)
|
||||
bi := mk(1, `{"count":2,"builtinModels":{"0":{"model_id":"GLM-5.2"},"1":{"model_id":"openpangu-2.0-pro"}}}`)
|
||||
t.Cleanup(gw.Close)
|
||||
t.Cleanup(bi.Close)
|
||||
h.gateway, h.builtin = gw.URL, bi.URL
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.ps = ps
|
||||
|
||||
// 把两个端点都指到假上游(顺序与平台配置一致)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"models_remote": map[string]interface{}{
|
||||
"endpoint0": h.gateway,
|
||||
"endpoint1": h.builtin,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
// 塞一个 AK/SK 账号(签名分支需要凭据,否则会走 "无可用 AK/SK 凭据")
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"id": "cat-test",
|
||||
"credential": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST",` +
|
||||
`"security_token":"STTEST","user_name":"cat"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestCodeArtsCatalogPullsBothSignedEndpoints(t *testing.T) {
|
||||
h := newCodeartsCatalogHarness(t)
|
||||
|
||||
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
|
||||
t.Fatalf("model_refresh: %v", err)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
hits := map[int]int{}
|
||||
for k, v := range h.hits {
|
||||
hits[k] = v
|
||||
}
|
||||
authSeen := append([]string(nil), h.authSeen...)
|
||||
h.mu.Unlock()
|
||||
|
||||
// ① 两个端点都必须被命中(只打一个是真实缺口)
|
||||
if hits[0] == 0 {
|
||||
t.Fatal("gateway/config 端点从未被调用 —— benefit 模型(glm-5.3-flash 等)永远拿不到")
|
||||
}
|
||||
if hits[1] == 0 {
|
||||
t.Fatal("snap /v1/model/builtin 端点从未被调用 —— 常规模型(GLM-5.2 / openpangu)永远拿不到")
|
||||
}
|
||||
// ② 每个请求都必须带 AK/SK 签名特征头(Bearer 打不通这两个端点)
|
||||
for i, seen := range authSeen {
|
||||
parts := strings.Split(seen, "|")
|
||||
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
||||
t.Fatalf("第 %d 个请求缺签名头(X-Sdk-Date/Content-Sha256/Authorization):%q", i+1, seen)
|
||||
}
|
||||
if !strings.Contains(parts[2], "SDK-HMAC-SHA256") {
|
||||
t.Fatalf("Authorization 不是华为 SDK-HMAC-SHA256 形态,实得 %q", parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 合并结果必须同时含两个端点的模型(字段名不同:model_id,两边都要认)
|
||||
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := modelIDs(t, got)
|
||||
joined := strings.Join(ids, ",")
|
||||
for _, want := range []string{"glm-5.3-flash", "GLM-5.2", "openpangu-2.0-pro"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("合并目录缺 %s(两个端点必须**合并**而非只用其一):%v", want, ids)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 两端点各命中 %d/%d 次,合并得到 %d 个模型", hits[0], hits[1], len(ids))
|
||||
}
|
||||
|
||||
// 兜底表在远端失败时必须仍然可用(不能因为加了目录就让平台变空)。
|
||||
func TestCodeArtsCatalogKeepsFallbackOnFailure(t *testing.T) {
|
||||
h := newCodeartsCatalogHarness(t)
|
||||
// 让两个端点都 500
|
||||
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
}))
|
||||
defer dead.Close()
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"models_remote": map[string]interface{}{
|
||||
"endpoint0": dead.URL, "endpoint1": dead.URL,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
|
||||
t.Fatalf("远端失败不该让 model_refresh 报错: %v", err)
|
||||
}
|
||||
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := modelIDs(t, got)
|
||||
if len(ids) == 0 {
|
||||
t.Fatal("远端全失败时目录变空了 —— 兜底表必须补位(宁可留旧目录也别让平台变空)")
|
||||
}
|
||||
t.Logf("✓ 远端失败时兜底目录仍有 %d 个模型: %v", len(ids), ids)
|
||||
}
|
||||
186
internal/core/channels_codearts_oauth_flow_test.go
Normal file
186
internal/core/channels_codearts_oauth_flow_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// CodeArts 端到端:起真实回调服务器 → 模上游回 code → 走真实 STS 交换
|
||||
// (带 DPoP)→ 凭据落盘。判据检查**交换请求本身的形态**(DPoP 头、
|
||||
// grant_type、code_verifier、redirect_uri 端口),因为只断言"存了个 token"
|
||||
// 无法防止把 OAuth 悄悄换回手填。
|
||||
func TestCodeArtsOAuthEndToEnd(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 假 STS 上游:校验请求形态,返回 credentials + refresh_token
|
||||
var gotForm url.Values
|
||||
var gotDpop string
|
||||
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotDpop = r.Header.Get("DPoP")
|
||||
_ = r.ParseForm()
|
||||
gotForm = r.PostForm
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"credentials": map[string]interface{}{
|
||||
"access_key_id": "AKTEST",
|
||||
"secret_access_key": "SKTEST",
|
||||
"security_token": "STTEST",
|
||||
"expiration": "2026-12-31T00:00:00Z",
|
||||
},
|
||||
"refresh_token": "RT-TEST",
|
||||
})
|
||||
}))
|
||||
defer sts.Close()
|
||||
|
||||
// 把 STS 端点指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"codearts_sts": map[string]interface{}{"url": sts.URL},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
|
||||
// 1) start_login → 拿回调地址与登录 URL
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("start_login 报错: %v", m["error"])
|
||||
}
|
||||
loginURL, _ := m["url"].(string)
|
||||
redirectURI, _ := m["redirect_uri"].(string)
|
||||
sess := m["session"].(map[string]interface{})
|
||||
sid, _ := sess["id"].(string)
|
||||
t.Logf("login_url = %s", loginURL)
|
||||
t.Logf("redirect_uri = %s", redirectURI)
|
||||
|
||||
// 2) 登录 URL 必须逐项符合参考 buildOAuthLoginUrl
|
||||
if !strings.HasPrefix(loginURL, "https://codearts.huaweicloud.com/portal/authorize?") {
|
||||
t.Fatalf("authorize base 不对: %s", loginURL)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"client_id=codearts-agent",
|
||||
"uri_scheme=codearts-agent",
|
||||
"code_challenge_method=SHA-256", // 不是 S256
|
||||
"plugin-name=snap_AIIDE",
|
||||
"plugin-version=5.2.0",
|
||||
"theme=2",
|
||||
"locale=zh-cn",
|
||||
} {
|
||||
if !strings.Contains(loginURL, want) {
|
||||
t.Errorf("authorize URL 缺 %q\n实际: %s", want, loginURL)
|
||||
}
|
||||
}
|
||||
// 反向:**不得**含 auth_callback_url(参考注释:会被 portal 判异常并回退旧流程)
|
||||
if strings.Contains(loginURL, "auth_callback_url") {
|
||||
t.Errorf("authorize URL 不应含 auth_callback_url(会导致 portal 回退旧流程): %s", loginURL)
|
||||
}
|
||||
// code_challenge 必须存在且非空
|
||||
if !strings.Contains(loginURL, "code_challenge=") ||
|
||||
strings.Contains(loginURL, "code_challenge=&") {
|
||||
t.Errorf("缺 code_challenge: %s", loginURL)
|
||||
}
|
||||
|
||||
// 3) 模拟上游重定向到回调(带 code)
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("code", "AUTHCODE-TEST")
|
||||
u.RawQuery = q.Encode()
|
||||
resp, err := http.Get(u.String())
|
||||
if err != nil {
|
||||
t.Fatalf("打回调: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// 4) poll_login → 触发真实 STS 交换
|
||||
pout, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("poll_login: %v", err)
|
||||
}
|
||||
pm := pout.JSON.(map[string]interface{})
|
||||
if pm["state"] != "completed" && pm["state"] != "success" && pm["state"] != "done" {
|
||||
t.Fatalf("poll_login 未成功: %#v", pm)
|
||||
}
|
||||
|
||||
// 5) 交换请求形态(判据的核心)
|
||||
if gotDpop == "" {
|
||||
t.Fatal("STS 请求缺 DPoP 头 —— 不是新式 OAuth")
|
||||
}
|
||||
if strings.Count(gotDpop, ".") != 2 {
|
||||
t.Errorf("DPoP 应是三段 JWS,实得 %q", gotDpop)
|
||||
}
|
||||
if gotForm.Get("grant_type") != "authorization_code" {
|
||||
t.Errorf("grant_type = %q,应为 authorization_code", gotForm.Get("grant_type"))
|
||||
}
|
||||
if gotForm.Get("client_id") != "codearts-agent" {
|
||||
t.Errorf("client_id = %q", gotForm.Get("client_id"))
|
||||
}
|
||||
if gotForm.Get("code_verifier") == "" {
|
||||
t.Error("缺 code_verifier —— PKCE 没生效")
|
||||
}
|
||||
if gotForm.Get("code") != "AUTHCODE-TEST" {
|
||||
t.Errorf("code = %q", gotForm.Get("code"))
|
||||
}
|
||||
ruWant := "http://127.0.0.1" + u.Port() + "/oauth/callback"
|
||||
if !strings.HasPrefix(redirectURI, ruWant[:len("http://127.0.0.1")]) {
|
||||
t.Errorf("redirect_uri 主机不对: %s", redirectURI)
|
||||
}
|
||||
if gotForm.Get("redirect_uri") != redirectURI {
|
||||
t.Errorf("redirect_uri = %q,应为回调地址 %q", gotForm.Get("redirect_uri"), redirectURI)
|
||||
}
|
||||
|
||||
// 6) 凭据必须含续期材料(refresh_token / code_verifier / DPoP 私钥 JWK)
|
||||
// 凭据必须含**续期材料** —— 这是 OAuth 流程存在的意义。
|
||||
// 只断言"登录成功"无法防止凭据被截断成裸 AK/SK(那样 refresh 就废了)。
|
||||
raw, err := ps.Call("channels", "__codearts_cred_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("凭据探针: %v", err)
|
||||
}
|
||||
w, _ := raw.(map[string]interface{})
|
||||
cj, _ := w["json"].(map[string]interface{})
|
||||
if cj == nil {
|
||||
t.Fatalf("探针无 json: %#v", raw)
|
||||
}
|
||||
if cj["error"] != nil {
|
||||
t.Fatalf("凭据未落盘: %v (refs=%v)", cj["error"], cj["refs"])
|
||||
}
|
||||
t.Logf("凭据 ref=%v access_key_id=%v", cj["ref"], cj["access_key_id"])
|
||||
if cj["access_key_id"] != "AKTEST" {
|
||||
t.Errorf("access_key_id = %v,应为 AKTEST(STS 响应未正确解析)", cj["access_key_id"])
|
||||
}
|
||||
for _, k := range []string{"refresh_token", "code_verifier", "dpop_private_key_jwk"} {
|
||||
if cj[k] == nil || cj[k] == "" {
|
||||
t.Errorf("凭据缺 %s(无法静默续期)", k)
|
||||
}
|
||||
}
|
||||
if cj["refresh_token"] != "RT-TEST" {
|
||||
t.Errorf("refresh_token = %v,应为 RT-TEST", cj["refresh_token"])
|
||||
}
|
||||
_ = json.Marshal
|
||||
|
||||
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"session_id":"` + sid + `"}`, Role: "admin",
|
||||
})
|
||||
}
|
||||
112
internal/core/channels_codearts_oauth_test.go
Normal file
112
internal/core/channels_codearts_oauth_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CodeArts 的 DPoP 证明是标准 ES256 JWS。用 Go 标准库(crypto/ecdsa)当权威
|
||||
// 校验器验它 —— 只断言"能拼出字符串"是不够的:JWS 的 ES256 签名必须是
|
||||
// **raw r||s(各 32 字节)**,而 openssl 默认输出 DER;写错格式服务端会拒,
|
||||
// 而字符串看起来完全正常(这是最容易蒙混过去的一类缺陷)。
|
||||
func TestCodeArtsDpopJwsVerifiesAsES256(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ps.Call("channels", "__codearts_dpop_probe",
|
||||
map[string]interface{}{"htm": "POST", "htu": "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("无 json: %#v", got)
|
||||
}
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("探针报错: %v", m["error"])
|
||||
}
|
||||
jws, _ := m["jws"].(string)
|
||||
if jws == "" {
|
||||
t.Fatal("jws 为空")
|
||||
}
|
||||
parts := strings.Split(jws, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("JWS 应为三段,实得 %d 段", len(parts))
|
||||
}
|
||||
dec := func(s string) []byte {
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
t.Fatalf("base64url 解码失败: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
// ---- header ----
|
||||
var hdr map[string]interface{}
|
||||
if err := json.Unmarshal(dec(parts[0]), &hdr); err != nil {
|
||||
t.Fatalf("header 不是 JSON: %v", err)
|
||||
}
|
||||
if hdr["alg"] != "ES256" {
|
||||
t.Errorf("alg 应为 ES256,实得 %v", hdr["alg"])
|
||||
}
|
||||
if hdr["typ"] != "dpop+jwt" {
|
||||
t.Errorf("typ 应为 dpop+jwt,实得 %v", hdr["typ"])
|
||||
}
|
||||
jwk, _ := hdr["jwk"].(map[string]interface{})
|
||||
if jwk == nil {
|
||||
t.Fatal("header 必须内嵌 jwk(DPoP 要求公钥随证明走)")
|
||||
}
|
||||
// ---- 用 jwk 重建公钥 ----
|
||||
xb, err := base64.RawURLEncoding.DecodeString(jwk["x"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yb, err := base64.RawURLEncoding.DecodeString(jwk["y"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(xb) != 32 || len(yb) != 32 {
|
||||
t.Fatalf("P-256 的 x/y 应各 32 字节,实得 %d/%d", len(xb), len(yb))
|
||||
}
|
||||
pub := &ecdsa.PublicKey{Curve: elliptic.P256(),
|
||||
X: new(big.Int).SetBytes(xb), Y: new(big.Int).SetBytes(yb)}
|
||||
|
||||
// ---- payload ----
|
||||
var pl map[string]interface{}
|
||||
if err := json.Unmarshal(dec(parts[1]), &pl); err != nil {
|
||||
t.Fatalf("payload 不是 JSON: %v", err)
|
||||
}
|
||||
for _, k := range []string{"htm", "htu", "iat", "jti"} {
|
||||
if _, ok := pl[k]; !ok {
|
||||
t.Errorf("payload 缺 %s(照参考 signDpopJws)", k)
|
||||
}
|
||||
}
|
||||
if pl["htm"] != "POST" {
|
||||
t.Errorf("htm = %v", pl["htm"])
|
||||
}
|
||||
|
||||
// ---- 验签(关键:raw r||s,不是 DER)----
|
||||
sig := dec(parts[2])
|
||||
if len(sig) != 64 {
|
||||
t.Fatalf("ES256 签名必须是 64 字节 raw r||s,实得 %d 字节(若是 70-72 字节,说明漏了 DER→raw 转换)", len(sig))
|
||||
}
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if !ecdsa.Verify(pub, digest[:], r, s) {
|
||||
t.Fatal("ES256 验签失败 —— 签名内容或格式不对,服务端会拒绝这个 DPoP")
|
||||
}
|
||||
}
|
||||
65
internal/core/channels_codearts_port_test.go
Normal file
65
internal/core/channels_codearts_port_test.go
Normal file
@ -0,0 +1,65 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// CodeArts 的 portal 拒绝 <10000 的回调端口(参考 listenOnCallbackPort 的
|
||||
// MIN_CALLBACK_PORT = 10000 及 `assigned >= MIN_CALLBACK_PORT` 校验)。
|
||||
// 判据直接量**实际分配的端口**,不复用参考常量做断言来源。
|
||||
func TestCodeArtsLoopbackPortAtLeast10000(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
m, _ := out.JSON.(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("无 JSON: %#v", out)
|
||||
}
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("报错: %v", m["error"])
|
||||
}
|
||||
ru, _ := m["redirect_uri"].(string)
|
||||
if ru == "" {
|
||||
t.Fatalf("无 redirect_uri: %#v", m)
|
||||
}
|
||||
t.Logf("redirect_uri = %s", ru)
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(ru, "http://127.0.0.1:%d/", &port); err != nil {
|
||||
if _, err2 := fmt.Sscanf(ru, "http://localhost:%d/", &port); err2 != nil {
|
||||
t.Fatalf("解析端口失败: %v / %v (%s)", err, err2, ru)
|
||||
}
|
||||
}
|
||||
if port < 10000 {
|
||||
t.Fatalf("回调端口 %d < 10000 —— portal 会拒绝(参考 MIN_CALLBACK_PORT)", port)
|
||||
}
|
||||
// 回调路径也必须是注册值 /oauth/callback(参考 REDIRECT_PATH)
|
||||
if !hasSuffix(ru, "/oauth/callback") {
|
||||
t.Fatalf("回调路径应为 /oauth/callback,实得 %s", ru)
|
||||
}
|
||||
if s, _ := m["session"].(map[string]interface{}); s != nil {
|
||||
if id, _ := s["id"].(string); id != "" {
|
||||
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"session_id":"` + id + `"}`, Role: "admin",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasSuffix(s, suf string) bool {
|
||||
return len(s) >= len(suf) && s[len(s)-len(suf):] == suf
|
||||
}
|
||||
204
internal/core/channels_dead_model_test.go
Normal file
204
internal/core/channels_dead_model_test.go
Normal file
@ -0,0 +1,204 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 「已失效模型」判据必须严格 —— 误判的代价是把**可用模型藏 30 天**。
|
||||
//
|
||||
// 参考为这些判据写了很长的注释(都是实测误判教训),本判据把它们钉住:
|
||||
//
|
||||
// - `model_not_found` 在遥测/日志里常作**字段名**出现(后面还有内容),
|
||||
// 只在它"独占一句话成分"时才算阳性;
|
||||
// - rate limit / quota / unauthor / forbidden 一律**不是**下架;
|
||||
// - `expired` 必须带主语(token/session/key/…),否则会误杀正常下架文案
|
||||
// `model expired-v3 does not exist`;
|
||||
// - 只认 found/exist,**不认** available/supported(那是"换套餐就能恢复")。
|
||||
func TestDeadModelGoneJudgement(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
msg string
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
// ── 阳性:真的是下架 ──
|
||||
{"model not found", true, "经典文案"},
|
||||
{"unknown model: foo", true, "unknown model"},
|
||||
{"no such model", true, "no such model"},
|
||||
{"The model does not exist", true, "does not exist"},
|
||||
{"error: model_not_found", true, "独占一句话(行尾)"},
|
||||
{"model_not_found.", true, "后面是句末标点"},
|
||||
// ── 阴性:看着像但**不是**下架 ──
|
||||
{"error_class=model_not_found, retry allowed", false,
|
||||
"字段名后面还有内容(初版误判下架,实测教训)"},
|
||||
{"the model_not_found metric was not exported", false,
|
||||
"作为指标名出现(实测教训)"},
|
||||
{"rate limit exceeded for model foo", false, "限流换时段就能用"},
|
||||
{"quota exceeded", false, "额度问题"},
|
||||
{"insufficient balance", false, "余额问题"},
|
||||
{"401 unauthorized", false, "认证问题"},
|
||||
{"invalid api key", false, "认证问题"},
|
||||
{"token expired", false, "token 过期(带主语)"},
|
||||
// ⚠️ 这条最微妙:expired 无主语时**不**能触发 NOT_GONE,
|
||||
// 否则会误杀 "model expired-v3 does not exist" 这类真实下架文案。
|
||||
{"model expired-v3 does not exist", true,
|
||||
"expired 无主语 → 不算认证失败;does not exist 才是真判据"},
|
||||
// ⚠️ 两条更正(对照参考实现的实际行为,不是注释字面):
|
||||
// 1. `lease expired` 单独出现**不算**下架 —— 它是租约/认证问题。
|
||||
// 2. 即便带上 does not exist 也**仍不算**:参考的判定顺序是
|
||||
// NOT_GONE 先于 GONE(`isModelGoneError` 第 265 行先查排除表),
|
||||
// 一旦命中排除词就直接 false、不再看后面的不存在语义。
|
||||
// 我最初按注释字面以为"带主语才对、带上就想判下架",是读错了。
|
||||
{"the model lease expired", false, "租约/认证问题,不是下架"},
|
||||
{"the model lease expired and does not exist", false,
|
||||
"参考的顺序是 NOT_GONE 先行 ⇒ 命中排除词即 false"},
|
||||
// available/supported 不算(换套餐就能恢复)
|
||||
{"model not available in your region", false,
|
||||
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
|
||||
{"", false, "空串"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
out, err := ps.Call("channels", "__is_model_gone_probe",
|
||||
map[string]interface{}{"message": tc.msg})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
w, _ := out.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
got, _ := j["gone"].(bool)
|
||||
if got != tc.want {
|
||||
t.Errorf("isModelGone(%q) = %v,应为 %v(%s)", tc.msg, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录-剔除-恢复 的闭环,以及**不拿远端目录反推**这条纪律。
|
||||
func TestDeadModelRecordAndPrune(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before := modelIDsFor(t, ps, "zcode")
|
||||
if len(before) == 0 {
|
||||
t.Fatal("zcode 应有兜底模型")
|
||||
}
|
||||
victim := before[0]
|
||||
|
||||
// 记录一条阳性证据 → 该模型必须从列表消失
|
||||
if _, err := ps.Call("channels", "__record_dead_probe",
|
||||
map[string]interface{}{"platform": "zcode", "model_id": victim, "reason": "model not found"}); err != nil {
|
||||
t.Fatalf("记录探针: %v", err)
|
||||
}
|
||||
after := modelIDsFor(t, ps, "zcode")
|
||||
for _, id := range after {
|
||||
if id == victim {
|
||||
t.Errorf("已失效模型 %q 仍出现在列表里(应剔除)", victim)
|
||||
}
|
||||
}
|
||||
if len(after) != len(before)-1 {
|
||||
t.Errorf("剔除后应少一个:前 %d 后 %d", len(before), len(after))
|
||||
}
|
||||
|
||||
// model_clear_dead 应把它恢复回来(用户确认上游又上了 / 误判时用)
|
||||
m, err := ps.Call("channels", "model_clear_dead",
|
||||
map[string]interface{}{"platform": "zcode", "model_id": victim})
|
||||
if err != nil {
|
||||
t.Fatalf("model_clear_dead: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["ok"] != true {
|
||||
t.Fatalf("model_clear_dead 失败: %#v", m)
|
||||
}
|
||||
restored := modelIDsFor(t, ps, "zcode")
|
||||
found := false
|
||||
for _, id := range restored {
|
||||
if id == victim {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("clear 后 %q 应恢复,实得 %v", victim, restored)
|
||||
}
|
||||
}
|
||||
|
||||
// 目录闸门:失败后进入冷却,冷却期内不重复拉(照参考 remote-catalog-gate)。
|
||||
// 判据用冷却状态本身,不依赖真实网络。
|
||||
func TestRemoteCatalogGateCooldown(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 初始不冷却
|
||||
g := func() (bool, bool) {
|
||||
out, err := ps.Call("channels", "__catalog_gate_probe",
|
||||
map[string]interface{}{"platform": "zcode"})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
w, _ := out.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
began, _ := j["began"].(bool)
|
||||
cooling, _ := j["cooling"].(bool)
|
||||
return began, cooling
|
||||
}
|
||||
if _, cooling := g(); cooling {
|
||||
t.Error("初始不该处于冷却")
|
||||
}
|
||||
// 模拟一次失败 → 进冷却
|
||||
if _, err := ps.Call("channels", "__catalog_gate_fail_probe",
|
||||
map[string]interface{}{"platform": "zcode"}); err != nil {
|
||||
t.Fatalf("失败探针: %v", err)
|
||||
}
|
||||
began, cooling := g()
|
||||
if !cooling {
|
||||
t.Error("失败后必须进入冷却(否则会对坏上游反复打 —— 参考点名的首屏卡死)")
|
||||
}
|
||||
if began {
|
||||
t.Error("冷却期内不该再发起拉取")
|
||||
}
|
||||
}
|
||||
|
||||
// modelIDsFor 读某平台当前对外的模型 id 列表。
|
||||
func modelIDsFor(t *testing.T, ps interface {
|
||||
Call(string, string, map[string]interface{}) (interface{}, error)
|
||||
}, plat string) []string {
|
||||
t.Helper()
|
||||
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": plat})
|
||||
if err != nil {
|
||||
t.Fatalf("model_list(%s): %v", plat, err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
var out []string
|
||||
if arr, ok := j["models"].([]interface{}); ok {
|
||||
for _, it := range arr {
|
||||
if mm, ok := it.(map[string]interface{}); ok {
|
||||
if id, ok := mm["id"].(string); ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
40
internal/core/channels_device_test.go
Normal file
40
internal/core/channels_device_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChannelsDevicePollBehavior 是 device-poll 流的行为对齐测试。
|
||||
//
|
||||
// 决策表逐行搬自参考实现 pollMinimaxDeviceToken(minimax-oauth.ts),
|
||||
// 上游用假服务器模拟,逐次响应由测试控制。断言的是**行为**(会话状态推进),
|
||||
// 不是实现细节。决策表本体在 deviceHarness.runDecisionTable(与变异测试共用)。
|
||||
func TestChannelsDevicePollBehavior(t *testing.T) {
|
||||
h := newDeviceHarness(t, mustChannelsSrc(t))
|
||||
// begin 直查:user_code 透传、授权 URL、verifier 不回显
|
||||
j := h.begin()
|
||||
sess := j["session"].(map[string]interface{})
|
||||
if sess["user_code"] != "ABCD-EFGH" {
|
||||
t.Fatalf("begin 应透传 user_code, 实得 %v", sess["user_code"])
|
||||
}
|
||||
if j["url"] != "https://example/activate" {
|
||||
t.Fatalf("begin 应给授权 URL, 实得 %v", j["url"])
|
||||
}
|
||||
if _, has := j["device_verifier"]; has {
|
||||
t.Fatal("verifier 绝不能回显")
|
||||
}
|
||||
// 决策表全过
|
||||
if !h.runDecisionTable() {
|
||||
t.Fatal("device-poll 决策表未全过(见 runDecisionTable 内的行号)")
|
||||
}
|
||||
}
|
||||
|
||||
// mustChannelsSrc 读 bundled channels 源码。
|
||||
func mustChannelsSrc(t *testing.T) string {
|
||||
t.Helper()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return src
|
||||
}
|
||||
76
internal/core/channels_endpoint_url_test.go
Normal file
76
internal/core/channels_endpoint_url_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 源的 chat URL = `base_url + endpoint`(内核 provider.go ChatURL)。
|
||||
//
|
||||
// 真实缺陷(生产实测):多个渠道平台的 `base_url` 里塞了**完整路径**,
|
||||
// 而适配器默认 endpoint 又是 "/chat/completions" ⇒ 实际请求变成
|
||||
//
|
||||
// https://copilot.tencent.com/v2/chat/completions/chat/completions
|
||||
//
|
||||
// ⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,不是模型错误)。
|
||||
//
|
||||
// 受影响平台 8 个:buddy / cline / lobsterai / opencode / qoder / loomy /
|
||||
// raccoon(zcode 当时已用 endpoint 覆盖,形态正确)。
|
||||
//
|
||||
// ★ 为什么这个缺陷能活很久:**模型名被多个源服务**。
|
||||
//
|
||||
// `deepseek-v4.1-flash` 同时由 4 个源提供(alittokenplan/vela/oin/channels-buddy…)
|
||||
// ⇒ 网关在 buddy 源失败后会**继续尝试下一个源**并成功 ⇒ 用户看到"能回话",
|
||||
// 于是"buddy 通道是好的"这个结论**完全没有证据**。
|
||||
// 而 `hy3` 只由 buddy 一个源提供 ⇒ 一调就暴露真因。
|
||||
// ⇒ 判据必须断言**源的 URL 形态**(结构不变量),不能靠"某个模型能调通"。
|
||||
//
|
||||
// 判据:base_url 不得自带任何 chat 路径段(它只应是 host[+协议公共前缀]),
|
||||
// 路径必须在 `endpoint` 里。
|
||||
func TestChannelSourceBaseURLHasNoChatPath(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 只在平台定义区里找 base_url(避免误判注释/文档里的示例)
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
// 已知适配器会把这些路径各追加一次;出现即说明 base_url 侵占了 endpoint 的地盘
|
||||
banned := []string{"/chat/completions", "/v1/messages", "/generateContent", "/api/chat"}
|
||||
|
||||
found := 0
|
||||
for _, m := range declRe.FindAllStringSubmatch(s, -1) {
|
||||
url := m[1]
|
||||
found++
|
||||
for _, b := range banned {
|
||||
if strings.Contains(url, b) {
|
||||
t.Errorf("base_url 侵占了 endpoint 的路径:%s\n"+
|
||||
" 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),"+
|
||||
"适配器默认 endpoint=\"/chat/completions\" ⇒ 实际会请求 %s%s\n"+
|
||||
" 修法:base_url 只留 host,把 `%s` 移到该平台的 `endpoint = \"...\"`",
|
||||
url, url, b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Fatal("没扫到任何 base_url 声明(判据失效)")
|
||||
}
|
||||
t.Logf("✓ %d 个 base_url 均不含 chat 路径段", found)
|
||||
}
|
||||
|
||||
// ★ 反过来也要咬:说明该缺陷**确实已被修好**、而不是判据太松。
|
||||
// 用一段**故意的坏数据**验证判据的判别力(变异测试的静态等价物)。
|
||||
func TestChannelBaseURLJudgeBitesOnBadInput(t *testing.T) {
|
||||
bad := ` base_url = "https://copilot.tencent.com/v2/chat/completions",`
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
m := declRe.FindStringSubmatch(bad)
|
||||
if m == nil {
|
||||
t.Fatal("判据的正则匹配不到坏数据 ⇒ 判据无判别力(形同虚设)")
|
||||
}
|
||||
if !strings.Contains(m[1], "/chat/completions") {
|
||||
t.Fatal("坏数据未被识别 ⇒ 判据无判别力")
|
||||
}
|
||||
t.Log("✓ 判据对坏数据有判别力")
|
||||
}
|
||||
67
internal/core/channels_free_flag_test.go
Normal file
67
internal/core/channels_free_flag_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// `isFree` 的判定链(照参考 cline-models.ts)。
|
||||
//
|
||||
// ★ 修的是一个与注释自相矛盾的缺陷:cline 的 3 个模型来自远端 **free 数组**,
|
||||
// 却声明在 `models` 里 ⇒ 被标成收费。用户看不到哪个可以不花钱用。
|
||||
//
|
||||
// 参考的判定链(权威在前):
|
||||
//
|
||||
// ① 远端 free 数组(`recommended-models` 是**唯一权威**的免费集合;
|
||||
// `/api/v1/models` 的 460 个 id 里根本没有 cline-free/*)
|
||||
// ② 命名约定前缀 `cline-free/*`(远端未响应时的兜底)
|
||||
// ③ 声明的 models_free
|
||||
// ④ 能力表的 cost(models.dev)
|
||||
//
|
||||
// ⚠️ 免费是 **OR** 语义:多个来源任一确认免费就够。写成"后者覆盖前者"会让
|
||||
// 「远端说免费、能力表拉不到」退回收费 —— 正是这类静默降级。
|
||||
func TestIsFreeUsesAuthorityChain(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// cline 的免费模型必须被标成免费(远端/free_prefix/声明 任一命中即可)
|
||||
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "cline"})
|
||||
if err != nil {
|
||||
t.Fatalf("model_list: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
got := map[string]bool{}
|
||||
for _, it := range asArrInterface(j["models"]) {
|
||||
if mm, ok := it.(map[string]interface{}); ok {
|
||||
if id, ok := mm["id"].(string); ok {
|
||||
if f, ok := mm["isFree"].(bool); ok {
|
||||
got[id] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `cline-free/*` 前缀命中 ⇒ 必须免费(命名约定兜底)
|
||||
if !got["cline-free/mimo-v2.6-flash"] {
|
||||
t.Errorf("cline-free/* 前缀的模型必须免费(命名约定兜底),实得 %v", got)
|
||||
}
|
||||
// stealth/space-bunny-alpha 来自远端 free 数组,也必须免费
|
||||
if !got["stealth/space-bunny-alpha"] {
|
||||
t.Errorf("远端 free 数组里的模型必须免费,实得 %v", got)
|
||||
}
|
||||
|
||||
// 命名约定必须生效(远端未响应时唯一的兜底依据)
|
||||
probe, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
|
||||
"platform": "cline", "model_id": "cline-free/some-new-model",
|
||||
})
|
||||
pj, _ := probe.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if pj["is_free"] != true {
|
||||
t.Error("远端未响应时,`cline-free/*` 前缀必须能判出免费(这是兜底依据)")
|
||||
}
|
||||
// 反向:收费通道的同名模型**不得**被判免费
|
||||
//(参考点名:免费模型是独立 id,`cline-free/x` 与 `x` 是两个不同模型)
|
||||
probe2, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
|
||||
"platform": "cline", "model_id": "deepseek/deepseek-v4.1-flash",
|
||||
})
|
||||
pj2, _ := probe2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if pj2["is_free"] == true {
|
||||
t.Error("收费通道的模型不得因为前缀规则被判成免费")
|
||||
}
|
||||
}
|
||||
63
internal/core/channels_gate_test.go
Normal file
63
internal/core/channels_gate_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 源注册必须**串行 + 节流**,而不是并发 2、无间隔。
|
||||
//
|
||||
// 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量,
|
||||
// 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:
|
||||
//
|
||||
// GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过
|
||||
// GLM-5.3 74 次 200 21 次重试 6 次最终 429
|
||||
//
|
||||
// 两条措施缺一不可:
|
||||
//
|
||||
// ① **串行**(max_concurrent=1):同一时刻只允许一个上游请求在飞 ——
|
||||
// 并发发起必然浪费一个配额。
|
||||
// ② **最小间隔**(rpm):串行只保证"不重叠",不保证"有间隔" —— 连续放行的
|
||||
// 两次调用可能只隔几十毫秒,仍会撞并发窗口。
|
||||
//
|
||||
// 此前插件注册源时一律 max_concurrent=2 且**从不设 rpm** ⇒ 既会重叠又无间隔。
|
||||
func TestChannelSourcesAreSerialWithThrottle(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "gated",
|
||||
"credential": `{"access_token":"TOK"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 失败: %#v", m)
|
||||
}
|
||||
aid, _ := j["account_id"].(string)
|
||||
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
if !ok {
|
||||
t.Fatal("账号未出源")
|
||||
}
|
||||
// ① 串行
|
||||
if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 {
|
||||
t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"])
|
||||
}
|
||||
// ② 节流(rpm>0 才有间隔)
|
||||
if rpm, _ := toFloat(sm["rpm"]); rpm <= 0 {
|
||||
t.Errorf("源必须设 rpm>0(串行不保证间隔),实得 %v", sm["rpm"])
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
110
internal/core/channels_lobster_claim_test.go
Normal file
110
internal/core/channels_lobster_claim_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 有道每日活动领取是**三步流程**,参考注释点名:无可用活动是**正常业务状态**,
|
||||
// 不能显示成"领取失败"(否则用户会去查网络/账号,方向全错)。
|
||||
// 本判据钉住决策表的四类结果:inactive / already-claimed / success / failed。
|
||||
func TestLobsterActivityClaimDecisionTable(t *testing.T) {
|
||||
type c struct {
|
||||
name string
|
||||
slot string
|
||||
ctx string
|
||||
wantKind string
|
||||
wantCalled bool // 是否应发出 claim 请求
|
||||
}
|
||||
cases := []c{
|
||||
{"槽位不可用→inactive", `{"code":0,"data":{"slotState":"unavailable","activity":{"activityCode":"A1"}}}`, "", "inactive", false},
|
||||
{"无 activityCode→inactive", `{"code":0,"data":{"slotState":"available","activity":{}}}`, "", "inactive", false},
|
||||
{"今天已领→already-claimed", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
|
||||
`{"code":0,"data":{"state":{"claimedToday":true},"actions":["check_in"]}}`, "already-claimed", false},
|
||||
{"无 check_in 动作→inactive", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
|
||||
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["share"]}}`, "inactive", false},
|
||||
{"可领→success", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
|
||||
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["check_in"]}}`, "success", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claimHit := 0
|
||||
var claimBody string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/api/client-activities/slot":
|
||||
fmt.Fprint(w, tc.slot)
|
||||
case r.URL.Path == "/api/client-activities/A1/context":
|
||||
if tc.ctx == "" {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, tc.ctx)
|
||||
case r.URL.Path == "/api/client-activities/A1/actions/check_in":
|
||||
claimHit++
|
||||
b := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(b)
|
||||
claimBody = string(b)
|
||||
fmt.Fprint(w, `{"code":0,"data":{"result":{"creditsGranted":30}}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把账号基址指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "lobsterai",
|
||||
"lobster_activity_base": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 存入凭据(claim 需要结构化 credential 对象)
|
||||
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"platform": "lobsterai", "credential": string(cred), "account": "t",
|
||||
})
|
||||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: string(body),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: `{"platform":"lobsterai"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim_daily: %v", err)
|
||||
}
|
||||
j, _ := out.JSON.(map[string]interface{})
|
||||
oc, _ := j["outcome"].(map[string]interface{})
|
||||
if oc == nil {
|
||||
t.Fatalf("应返回 outcome 判别联合,实得 %v", j)
|
||||
}
|
||||
if k, _ := oc["kind"].(string); k != tc.wantKind {
|
||||
t.Fatalf("kind 应为 %s,实得 %v(%v)", tc.wantKind, oc["kind"], oc["message"])
|
||||
}
|
||||
if (claimHit > 0) != tc.wantCalled {
|
||||
t.Errorf("claim 请求次数 = %d,期望发出=%v", claimHit, tc.wantCalled)
|
||||
}
|
||||
if tc.wantCalled && claimBody == "" {
|
||||
t.Error("claim 请求体不应为空(要带 configRevision 与幂等键)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
75
internal/core/channels_lobster_exchange_test.go
Normal file
75
internal/core/channels_lobster_exchange_test.go
Normal file
@ -0,0 +1,75 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"llmsproxy/internal/lua"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLobsterExchangePreservesIdentity(t *testing.T) {
|
||||
var calls int
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
var b map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if b["authCode"] != "quote\"&code" || len(b["uuid"]) != 36 || b["firstKeyfrom"] == "" || b["firstKeyfrom"] == "cli" {
|
||||
t.Errorf("invalid exchange fields: %#v", b)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" || r.Header.Get("User-Agent") != "LobsterAI/0.1.0" {
|
||||
t.Error("wrong headers")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"code":0,"data":{"accessToken":"at-test","refreshToken":"rt-test","expiresIn":3600,"user":{"id":"uid1","userId":"account1","nickname":"test"}}}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The fixture changes only the transport destination, not the upstream contract.
|
||||
if _, err = ps.Call("channels", "set_endpoints", map[string]interface{}{"platform": "lobsterai", "callback": map[string]interface{}{"token_url": ts.URL}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
|
||||
req := &lua.HandlerRequest{Method: "GET", Query: map[string]string{"state": nonce, "authCode": "quote\"&code"}}
|
||||
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Status != 200 {
|
||||
t.Fatalf("callback: %#v", out)
|
||||
}
|
||||
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
|
||||
if err != nil || !ok {
|
||||
t.Fatal("missing credential", err)
|
||||
}
|
||||
var cred map[string]interface{}
|
||||
if err = json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "latest_keyfrom", "expires_at", "uid", "user_id"} {
|
||||
if cred[k] == nil || cred[k] == "" {
|
||||
t.Errorf("missing %s", k)
|
||||
}
|
||||
}
|
||||
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Status != 409 || calls != 1 {
|
||||
t.Fatalf("replay accepted: status=%d calls=%d", out.Status, calls)
|
||||
}
|
||||
}
|
||||
165
internal/core/channels_lobster_loopback_test.go
Normal file
165
internal/core/channels_lobster_loopback_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 有道登录闭环的判据:portal 会把授权重定向回 **本机 loopback 接收器**
|
||||
// (127.0.0.1:{port}/auth/callback 精确路径),而不是用户机器的 localhost、
|
||||
// 也不是本插件的 /api/plugins/http 挂载点。远程用户在服务端浏览器里完成后,
|
||||
// 重定向落本机端口,接收器写文件,poll_login 读它完成交换。
|
||||
//
|
||||
// 参考 lobsterai-oauth.ts 明确:redirect_uri 必须该形态(登录页会校验),
|
||||
// state 不匹配必须拒绝(400),交换体含 authCode/firstKeyfrom/latestKeyfrom/
|
||||
// uuid/version 五字段,凭据必须带 uuid+first_keyfrom(否则续期失败)。
|
||||
func TestLobsterLoopbackClosedLoop(t *testing.T) {
|
||||
var exch int
|
||||
var gotVersion string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
exch++
|
||||
var b map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
if b["authCode"] != "AC-1" {
|
||||
t.Errorf("exchange authCode = %q", b["authCode"])
|
||||
}
|
||||
gotVersion = b["version"]
|
||||
if b["firstKeyfrom"] == "" || b["uuid"] == "" {
|
||||
t.Errorf("exchange 缺字段: %#v", b)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"code":0,"data":{"accessToken":"AT-1","refreshToken":"RT-1","expiresIn":3600,"user":{"id":"UID1"}}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// 版本号是 exchange 的必填字段,**不能写死**:2026-10-13 实测上游已是
|
||||
// 2026.9.23,而参考的兜底常量还停在 2026.9.4。此处假上游返回真形态
|
||||
// (非统一信封,值在 data.value.version)。
|
||||
ver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"data":{"value":{"version":"2026.9.23","date":"2026-9-23"}}}`)
|
||||
}))
|
||||
defer ver.Close()
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "status"}, false)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "lobsterai",
|
||||
"callback": map[string]interface{}{"token_url": ts.URL},
|
||||
"client_version": map[string]interface{}{"url": ver.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
sess := j["session"].(map[string]interface{})
|
||||
nonce := sess["nonce"].(string)
|
||||
sid := sess["id"].(string)
|
||||
redirectURI, _ := j["redirect_uri"].(string)
|
||||
if !strings.HasPrefix(redirectURI, "http://127.0.0.1:") || !strings.HasSuffix(redirectURI, "/auth/callback") {
|
||||
t.Fatalf("redirect_uri 必须是 127.0.0.1:{port}/auth/callback,实得 %q", redirectURI)
|
||||
}
|
||||
url, _ := j["url"].(string)
|
||||
if !strings.Contains(url, "/portal#/login?") {
|
||||
t.Fatalf("授权页应是 /portal#/login?…,实得 %q", url)
|
||||
}
|
||||
if !strings.Contains(url, "source=electron") {
|
||||
t.Fatalf("授权页缺 source=electron: %q", url)
|
||||
}
|
||||
|
||||
t.Run("行1 state 不匹配被拒", func(t *testing.T) {
|
||||
resp, err := http.Get(redirectURI + "?code=AC-1&state=WRONG")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 400 {
|
||||
t.Fatalf("state 不匹配应 400,实得 %d", resp.StatusCode)
|
||||
}
|
||||
if exch != 0 {
|
||||
t.Fatal("state 不匹配不应触发交换")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 正确回调完成登录并落完整凭据", func(t *testing.T) {
|
||||
resp, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("合法回调应 200,实得 %d", resp.StatusCode)
|
||||
}
|
||||
// 接收器写文件后 poll_login 读它完成交换(可能需要一拍)
|
||||
var st map[string]interface{}
|
||||
for i := 0; i < 20; i++ {
|
||||
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st = o.JSON.(map[string]interface{})
|
||||
if st["state"] != "pending" {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("应完成登录,实得 %v", st)
|
||||
}
|
||||
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("凭据未落库: %v", err)
|
||||
}
|
||||
var cred map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "expires_at", "uid"} {
|
||||
if cred[k] == nil || cred[k] == "" {
|
||||
t.Errorf("凭据缺 %s(有道的 uuid/first_keyfrom 丢了就续期失败): %s", k, raw)
|
||||
}
|
||||
}
|
||||
if cred["access_token"] != "AT-1" {
|
||||
t.Fatalf("access_token = %v", cred["access_token"])
|
||||
}
|
||||
// 版本号必须来自远端(2026.9.23),不能是写死的兜底 2026.9.4
|
||||
if gotVersion != "2026.9.23" {
|
||||
t.Fatalf("exchange version 应为远端 2026.9.23(写死 2026.9.4 是 bug),实得 %q", gotVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 接收器用完即收", func(t *testing.T) {
|
||||
// 完成后监听应已关闭(连接被拒)
|
||||
_, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
|
||||
if err == nil {
|
||||
t.Fatal("接收器应在完成后关闭")
|
||||
} else if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "connect") {
|
||||
t.Logf("关闭判定: %v", err)
|
||||
}
|
||||
})
|
||||
_ = fmt.Sprint
|
||||
}
|
||||
190
internal/core/channels_log_panel_test.go
Normal file
190
internal/core/channels_log_panel_test.go
Normal file
@ -0,0 +1,190 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 插件日志面板:级别 + 归属 + 同文折叠(否则用户动作被 96% 的周期噪音淹没)。
|
||||
//
|
||||
// 真实问题(生产实测):50 条日志里 **48 条(96%)** 是三个定时任务
|
||||
// (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now)
|
||||
// 的重复回音,唯一模式只有 8 种 ⇒ 约 2 小时就把 50 条环形缓冲冲满一轮,
|
||||
// **真正的用户动作**(如「账号删除」)被挤到看不见。
|
||||
//
|
||||
// 三个结构性修法,每个都在源码里可断言:
|
||||
//
|
||||
// ① logEvent 支持 level(task/warn/error),前端默认折叠 task
|
||||
// ② logEvent 支持 platform,前端按选中渠道过滤(修"点 buddy 看到 trae"的归属错误)
|
||||
// ③ 同文折叠:紧邻同文不新增条目,只 n=n+1
|
||||
func TestChannelsLogEventCarriesLevelAndPlatform(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 签名必须收 level 与 platform(三参)
|
||||
if !regexp.MustCompile(`local function logEvent\(msg, level, platform\)`).MatchString(s) {
|
||||
t.Error("logEvent 必须接收 (msg, level, platform) —— 否则级别与渠道归属都无从表达")
|
||||
}
|
||||
// ② 写入的条目必须带上这两个字段
|
||||
for _, field := range []string{"level = level or", "platform = platform"} {
|
||||
if !strings.Contains(s, field) {
|
||||
t.Errorf("日志条目必须写入 %s", field)
|
||||
}
|
||||
}
|
||||
// ③ 同文折叠:紧邻同文时只加计数
|
||||
if !strings.Contains(s, "top.n = (top.n or 1) + 1") {
|
||||
t.Error("必须实现紧邻同文折叠(否则周期任务的重复回音会把 50 条缓冲冲满)")
|
||||
}
|
||||
// ④ 折叠必须只针对**紧邻**条目 —— 否则一条每小时出现的消息会不断跳到最前,
|
||||
// 时间线失去意义。断言比较对象是 log[1](栈顶)而不是任意条目。
|
||||
if !strings.Contains(s, "local top = log[1]") {
|
||||
t.Error("折叠必须只比较**紧邻**的上一条(log[1]);对任意条目折叠会让时间线错乱")
|
||||
}
|
||||
t.Log("✓ logEvent 具备 level / platform / 紧邻同文折叠")
|
||||
}
|
||||
|
||||
// ★ 周期任务的**常规**结果必须是 task 级(默认折叠),异常必须是 warn/error。
|
||||
//
|
||||
// 判据咬的是"这些具体调用点被降级了",而不是"存在 task 这个词" ——
|
||||
// 后者只要有一处就为真,测不出"某个噪音源忘了降级"。
|
||||
func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 这些是「每次跑都成功」的常规回音 —— 必须 task 级
|
||||
routine := []string{
|
||||
`logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")`,
|
||||
`logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")`,
|
||||
`logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")`,
|
||||
}
|
||||
for _, r := range routine {
|
||||
if !strings.Contains(s, r) {
|
||||
t.Errorf("周期任务的常规结果必须降级为 task(否则会淹没用户动作):\n %s", r)
|
||||
}
|
||||
}
|
||||
// 这些是异常 —— 必须 warn(不能被一起折叠掉)
|
||||
for _, pat := range []string{
|
||||
`logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")`,
|
||||
`logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")`,
|
||||
`logEvent("凭据续期失败 " .. #failed .. " 个:`,
|
||||
} {
|
||||
if !strings.Contains(s, pat) {
|
||||
t.Errorf("异常必须保持 warn(不能被当常规噪音折叠):%s", pat)
|
||||
}
|
||||
}
|
||||
// 「已同步源」是周期重同步的常规回音 —— 降级为 task
|
||||
if !strings.Contains(s, `logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider)`) {
|
||||
t.Error("「已同步源」是周期重同步的常规回音,必须 task 级且带平台归属")
|
||||
}
|
||||
t.Log("✓ 周期噪音已降级,异常保持 warn")
|
||||
}
|
||||
|
||||
// ★ 前端的三个过滤维度必须都在(级别门槛 / 例行折叠 / 按渠道过滤)。
|
||||
func TestChannelsLogPanelHasFilters(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 日志卡片必须**独立于渠道详情**(C 的核心:修正归属)
|
||||
// 判据:日志卡片的 DOM 不是 ch-detail 的子元素。
|
||||
// 做法:取 ch-detail 开始到 ch-log-card 之间的片段,要求中间出现过
|
||||
// ch-detail 的闭合(即两者是兄弟而非父子)。
|
||||
iDetail := strings.Index(s, `id="ch-detail"`)
|
||||
iLogCard := strings.Index(s, `id="ch-log-card"`)
|
||||
if iDetail < 0 || iLogCard < 0 {
|
||||
t.Fatal("找不到 ch-detail 或 ch-log-card(判据需更新)")
|
||||
}
|
||||
if iLogCard < iDetail {
|
||||
t.Fatal("运行日志卡片应在渠道详情**之后**(作为兄弟节点)")
|
||||
}
|
||||
// 统计 ch-detail 打开到日志卡片之间未闭合的 div 层数
|
||||
seg := s[iDetail:iLogCard]
|
||||
depth := 0
|
||||
for _, m := range regexp.MustCompile(`</?div`).FindAllString(seg, -1) {
|
||||
if strings.HasPrefix(m, "</") {
|
||||
depth--
|
||||
} else {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
if depth > 0 {
|
||||
t.Errorf("运行日志卡片仍嵌在渠道详情内部(depth=%d)⇒ 点开某渠道会看到别的渠道的日志(归属错误)", depth)
|
||||
}
|
||||
|
||||
// ② 三个过滤维度
|
||||
for _, id := range []string{`id="ch-log-verbose"`, `id="ch-log-level"`} {
|
||||
if !strings.Contains(s, id) {
|
||||
t.Errorf("缺少日志视图控件 %s", id)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s, "function chLogRender()") {
|
||||
t.Error("必须实现 chLogRender()(否则过滤逻辑无处落地)")
|
||||
}
|
||||
// ③ 按渠道过滤 + 切渠道重渲染
|
||||
if !strings.Contains(s, "if (selPlat) return e.platform === selPlat;") {
|
||||
t.Error("必须按当前选中渠道过滤日志(否则归属错误以另一种形式回来)")
|
||||
}
|
||||
if !strings.Contains(s, "chLogRender();\n };\n") {
|
||||
t.Error("chSelect 切换渠道后必须重渲染日志")
|
||||
}
|
||||
t.Log("✓ 日志面板独立成卡片,三个过滤维度齐备")
|
||||
}
|
||||
|
||||
// ★ 日志截断必须按**字符**切,不能按字节 —— 否则中文被切出无效 UTF-8。
|
||||
//
|
||||
// 真实缺陷(生产实测,v2.0.33 截图里可见):日志尾显示成
|
||||
//
|
||||
// `能力增强不<E5BCBA>`
|
||||
//
|
||||
// 根因:`tostring(cerr):sub(1, 120)` —— Lua 的 sub 按**字节**切,而中文占 3 字节
|
||||
// ⇒ 切在字符中间产生替换字符(U+FFFD)。一句本来完整的诊断看起来像数据损坏。
|
||||
func TestLogTruncationIsUTF8Safe(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 必须存在按字符截断的助手
|
||||
if !strings.Contains(s, "plugin.utf8Clip = function(s, maxBytes)") {
|
||||
t.Fatal("必须实现 plugin.utf8Clip(按字符截断)")
|
||||
}
|
||||
// ② 中文文案上的截断不得再用裸 sub()
|
||||
for _, bad := range []string{
|
||||
`tostring(cres):sub(1, 120)`,
|
||||
`tostring(cerr):sub(1, 120)`,
|
||||
`table.concat(failed, "; "):sub(1, 300)`,
|
||||
} {
|
||||
if strings.Contains(s, bad) {
|
||||
t.Errorf("中文文案的截断不能用裸 sub()(按字节切会产生 U+FFFD):%s", bad)
|
||||
}
|
||||
}
|
||||
// ③ 三处必须已换成 utf8Clip
|
||||
for _, want := range []string{
|
||||
"plugin.utf8Clip(cres, 120)",
|
||||
"plugin.utf8Clip(cerr, 120)",
|
||||
`plugin.utf8Clip(table.concat(failed, "; "), 300)`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("缺少 UTF-8 安全截断调用:%s", want)
|
||||
}
|
||||
}
|
||||
// ④ 助手必须**只在超长时**才截断(否则正常文案会被莫名缩短)
|
||||
if !strings.Contains(s, "if #s <= maxBytes then return s end") {
|
||||
t.Error("utf8Clip 必须对未超长的输入原样返回")
|
||||
}
|
||||
// ⑤ 回退逻辑必须处理续字节(10xxxxxx)与孤立首字节
|
||||
if !strings.Contains(s, "elseif b >= 0xC0 then") {
|
||||
t.Error("utf8Clip 必须回退孤立的 UTF-8 首字节(否则末尾仍是半个字符)")
|
||||
}
|
||||
t.Log("✓ 截断已 UTF-8 安全")
|
||||
}
|
||||
177
internal/core/channels_loomy_test.go
Normal file
177
internal/core/channels_loomy_test.go
Normal file
@ -0,0 +1,177 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// loomyHarness:Loomy 微信扫码长轮询的测试台。
|
||||
//
|
||||
// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"):
|
||||
//
|
||||
// 408 = 等待扫码;404 = 已扫码待确认(继续轮询);
|
||||
// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效
|
||||
type loomyHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
polls int32
|
||||
confirmAt int32
|
||||
gotAuth string
|
||||
}
|
||||
|
||||
func newLoomyHarness(t *testing.T) *loomyHarness {
|
||||
t.Helper()
|
||||
h := &loomyHarness{t: t, confirmAt: 3}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"):
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
// 夹具必须用**真实微信页面的形态**:uuid 内嵌在
|
||||
// <img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>。
|
||||
// 用别的形态(如 var uuid = "...")测出来的正则不是生产要用的那条。
|
||||
fmt.Fprint(w, `<html><img class="js_qrcode_img" src="/connect/qrcode/UUID-TEST-1"/></html>`)
|
||||
case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"):
|
||||
n := atomic.AddInt32(&h.polls, 1)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if n == 1 {
|
||||
fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码
|
||||
return
|
||||
}
|
||||
if n < atomic.LoadInt32(&h.confirmAt) {
|
||||
fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认
|
||||
case r.URL.Path == "/login/thirdAccount/bind/auth":
|
||||
h.gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "loomy",
|
||||
"wechat": map[string]interface{}{
|
||||
"auth_url": h.ts.URL + "/connect/qrconnect?state={state}",
|
||||
"qr_url": h.ts.URL + "/connect/qrcode/{uuid}",
|
||||
"poll_url": h.ts.URL + "/connect/l/qrconnect",
|
||||
"account_base": h.ts.URL,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *loomyHarness) begin() (map[string]interface{}, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
func (h *loomyHarness) pollOnce(sid string) map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。
|
||||
func TestLoomyWechatPollBehavior(t *testing.T) {
|
||||
h := newLoomyHarness(t)
|
||||
|
||||
t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
j, _ := h.begin()
|
||||
qr, _ := j["qr_url"].(string)
|
||||
if !strings.Contains(qr, "UUID-TEST-1") {
|
||||
t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.confirmAt, 99)
|
||||
_, sid := h.begin()
|
||||
// 第一拍 408
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("408 应 pending, 实得 %v", st["state"])
|
||||
}
|
||||
// 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里)
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "pending" {
|
||||
t.Fatalf("404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.confirmAt, 3)
|
||||
_, sid := h.begin()
|
||||
h.pollOnce(sid) // 408
|
||||
h.pollOnce(sid) // 404
|
||||
st := h.pollOnce(sid) // 405
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"])
|
||||
}
|
||||
// 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer
|
||||
if !strings.HasPrefix(h.gotAuth, "account ") {
|
||||
t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth)
|
||||
}
|
||||
if !strings.Contains(h.gotAuth, ":") {
|
||||
t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth)
|
||||
}
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:loomy")
|
||||
if !ok || !strings.Contains(v, "RCODE-1") {
|
||||
t.Fatalf("凭据应含 rcode, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) {
|
||||
for _, tc := range []struct{ errcode, want string }{
|
||||
{"403", "取消"}, {"402", "失效"},
|
||||
} {
|
||||
hh := newLoomyHarness(t)
|
||||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") {
|
||||
fmt.Fprint(w, `<img src="/connect/qrcode/U">`)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode)
|
||||
})
|
||||
_, sid := hh.begin()
|
||||
st := hh.pollOnce(sid)
|
||||
if st["state"] != "failed" {
|
||||
t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
201
internal/core/channels_models_remote_test.go
Normal file
201
internal/core/channels_models_remote_test.go
Normal file
@ -0,0 +1,201 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 远端模型目录的判据:**远端为主 + 兜底补位**(照参考 reconcileWithFallback)。
|
||||
//
|
||||
// 参考明确:"正确做法是**合并**,而不是「远端失败就用兜底」的简单回退"
|
||||
// (cline-models.ts)。故这里逐条锁定:
|
||||
// - 远端有的以远端为准(顺序也照远端)
|
||||
// - 远端没有的用兜底表补位
|
||||
// - 拉取失败**不清空**既有缓存(宁可留旧目录也别让平台变空)
|
||||
// - 平台声明的真实目录 key 必须在上游源里(占位名会让选模型调不通)
|
||||
type modelsHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
mu sync.Mutex
|
||||
body string
|
||||
code int
|
||||
hits int
|
||||
}
|
||||
|
||||
func newModelsHarness(t *testing.T, platform, body string) *modelsHarness {
|
||||
t.Helper()
|
||||
h := &modelsHarness{t: t, body: body, code: 200}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
h.hits++
|
||||
code, body := h.code, h.body
|
||||
h.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
// 说明:测试上游是 127.0.0.1(loopback),出站白名单默认允许,
|
||||
// 不需要额外授权 —— 也就不需要碰内核的授权 API。
|
||||
// 把该平台的远端端点指到假上游
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": platform,
|
||||
"models_remote": map[string]interface{}{
|
||||
"endpoint0": h.ts.URL,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Logf("set_endpoints(models_remote) 未支持: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// modelIDs 从 model_list 的 Call 返回值({json:{models:[...]}})取 id 列表。
|
||||
func modelIDs(t *testing.T, got interface{}) []string {
|
||||
t.Helper()
|
||||
j := got.(map[string]interface{})["json"].(map[string]interface{})
|
||||
ms, _ := j["models"].([]interface{})
|
||||
ids := make([]string, 0, len(ms))
|
||||
for _, e := range ms {
|
||||
ids = append(ids, e.(map[string]interface{})["id"].(string))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestRemoteModelCatalogMergesWithFallback(t *testing.T) {
|
||||
// zcode:远端 builtinModels 是**对象**(键为序号),且远端/兜底有重叠
|
||||
body := `{"data":{"builtinModels":{"0":{"modelId":"GLM-5.3"},"1":{"modelId":"GLM-5.3-Flash"},"2":{"modelId":"NEW-MODEL-X"}}}}`
|
||||
h := newModelsHarness(t, "zcode", body)
|
||||
|
||||
t.Run("行1 远端拉到就用远端并保留兜底独有的", func(t *testing.T) {
|
||||
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]interface{})["json"].(map[string]interface{})
|
||||
res := m["results"].([]interface{})[0].(map[string]interface{})
|
||||
if res["ok"] != true {
|
||||
t.Fatalf("刷新应成功, 实得 %v", res)
|
||||
}
|
||||
// NEW-MODEL-X 只由远端下发,必须出现(证明用的不是硬编码表)
|
||||
ml, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := modelIDs(t, ml)
|
||||
joined := strings.Join(ids, ",")
|
||||
if !strings.Contains(joined, "NEW-MODEL-X") {
|
||||
t.Fatalf("只由远端下发的模型必须出现(否则是硬编码表): %v", ids)
|
||||
}
|
||||
if !strings.Contains(joined, "GLM-5.3") {
|
||||
t.Fatalf("兜底/远端共有项应保留: %v", ids)
|
||||
}
|
||||
// 远端与兜底重叠项不得重复
|
||||
seen := map[string]int{}
|
||||
for _, id := range ids {
|
||||
seen[id]++
|
||||
if seen[id] > 1 {
|
||||
t.Fatalf("模型 %s 重复出现(合并应去重): %v", id, ids)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 拉取失败不清空既有缓存", func(t *testing.T) {
|
||||
// 先成功一次建立缓存
|
||||
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 再让上游 500
|
||||
h.mu.Lock()
|
||||
h.code = 500
|
||||
h.body = `{}`
|
||||
h.mu.Unlock()
|
||||
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := got.(map[string]interface{})["json"].(map[string]interface{})["results"].([]interface{})[0].(map[string]interface{})
|
||||
if res["ok"] == true {
|
||||
t.Fatalf("上游 500 时刷新应报失败, 实得 %v", res)
|
||||
}
|
||||
ml, _ := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
|
||||
ids := modelIDs(t, ml)
|
||||
if !strings.Contains(strings.Join(ids, ","), "NEW-MODEL-X") {
|
||||
t.Fatalf("拉取失败必须保留既有目录(不能让平台变空): %v", ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPlatformModelsAreRealCatalogKeys 锁定 12 平台的模型名**不是编的占位名**。
|
||||
// 此前 qoder/zcode/loomy/raccoon 用的是 "*-default"(我编的),上游不认 ——
|
||||
// 后果是这些平台登录成功后选任何模型都调不通,而测试全绿(它测的就是我编的名字)。
|
||||
func TestPlatformModelsAreRealCatalogKeys(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ps.Call("channels", "provider_status", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plats := got.(map[string]interface{})["json"].(map[string]interface{})["statuses"].([]interface{})
|
||||
if len(plats) != 12 {
|
||||
t.Fatalf("应有 12 个真实平台, 实得 %d", len(plats))
|
||||
}
|
||||
realQoder := []string{"qfmodel", "qmodel_38max", "kmodel_latest", "dmodel"}
|
||||
sawQoder := 0
|
||||
for _, e := range plats {
|
||||
p := e.(map[string]interface{})
|
||||
id := p["id"].(string)
|
||||
ml, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": id})
|
||||
if err != nil {
|
||||
t.Fatalf("平台 %s model_list: %v", id, err)
|
||||
}
|
||||
ids := modelIDs(t, ml)
|
||||
if len(ids) == 0 {
|
||||
t.Fatalf("平台 %s 无模型", id)
|
||||
}
|
||||
for _, mid := range ids {
|
||||
// 占位名特征:以 -default 结尾
|
||||
if strings.HasSuffix(mid, "-default") {
|
||||
t.Fatalf("平台 %s 的模型 %q 是编的占位名(上游不认,选了调不通)", id, mid)
|
||||
}
|
||||
}
|
||||
if id == "qoder" {
|
||||
j := map[string]bool{}
|
||||
for _, mid := range ids {
|
||||
j[mid] = true
|
||||
}
|
||||
for _, want := range realQoder {
|
||||
if j[want] {
|
||||
sawQoder++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sawQoder == 0 {
|
||||
t.Fatal("qoder 应含参考的真实目录 key(如 qfmodel),而非编造的占位名")
|
||||
}
|
||||
}
|
||||
405
internal/core/channels_more_test.go
Normal file
405
internal/core/channels_more_test.go
Normal file
@ -0,0 +1,405 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// channelsMoreTest:新引入的三条真实流程 + 两个边界流的行为对齐测试。
|
||||
// 平台定义、决策表、端点逐字照抄参考(见 channels.lua 注释),测试照参考行为搬。
|
||||
|
||||
// ---- Cline(WorkOS 设备码 + register 两步) ----
|
||||
|
||||
// clineHarness 假上游:device code / authenticate / register 三端点。
|
||||
type clineHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
tok atomic.Value // 当前 authenticate 响应脚本 []scriptStep
|
||||
step int32
|
||||
}
|
||||
|
||||
func newClineHarness(t *testing.T) *clineHarness {
|
||||
t.Helper()
|
||||
h := &clineHarness{t: t}
|
||||
h.tok.Store([]scriptStep{})
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/user_management/authorize/device":
|
||||
fmt.Fprint(w, `{"device_code":"CD1","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
|
||||
case "/user_management/authenticate":
|
||||
i := int(atomic.AddInt32(&h.step, 1)) - 1
|
||||
s := h.tok.Load().([]scriptStep)
|
||||
if i >= len(s) {
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprint(w, `{"error":"unexpected"}`)
|
||||
return
|
||||
}
|
||||
if s[i].http != 0 && s[i].http != 200 {
|
||||
w.WriteHeader(s[i].http)
|
||||
}
|
||||
fmt.Fprint(w, s[i].body)
|
||||
case "/api/v1/auth/register":
|
||||
// 校验请求体是驼峰 accessToken/refreshToken(参考 registerClineTokens)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
for _, want := range []string{`"accessToken":"WO-AT-1"`, `"refreshToken":"WO-RT-1"`} {
|
||||
if !strings.Contains(string(b), want) {
|
||||
t.Errorf("register 请求体缺 %s: %s", want, b)
|
||||
}
|
||||
}
|
||||
fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-AT-1","refreshToken":"CLINE-RT-1","userInfo":{"clineUserId":"u1","email":"dev@example.com"}}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, _ := readBundledChannels(t)
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "cline",
|
||||
"device": map[string]interface{}{"code_url": h.ts.URL + "/user_management/authorize/device", "token_url": h.ts.URL + "/user_management/authenticate"},
|
||||
"register": map[string]interface{}{"url": h.ts.URL + "/api/v1/auth/register"},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *clineHarness) begin() map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"cline"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
func (h *clineHarness) poll(id string) map[string]interface{} {
|
||||
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": id},
|
||||
})
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// runWorkosTable 是 WorkOS(Cline)分支的行为契约,供变异测试杀 workos 变异体。
|
||||
// 决策表 5 行,跟 TestClineWorkOSBehavior 同源(这里只返回布尔,不报细节)。
|
||||
func (h *clineHarness) runWorkosTable() bool {
|
||||
st := func(steps ...string) map[string]interface{} {
|
||||
h.tok.Store(scriptSteps(steps...))
|
||||
atomic.StoreInt32(&h.step, 0)
|
||||
j := h.begin()
|
||||
sid := j["session"].(map[string]interface{})["id"].(string)
|
||||
return h.poll(sid)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
// 行1: 非200+error=authorization_pending → 等
|
||||
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
|
||||
return false
|
||||
}
|
||||
// 行2: 非200+error=slow_down → 等 + interval+1(基线 1)
|
||||
h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`))
|
||||
atomic.StoreInt32(&h.step, 0)
|
||||
j := h.begin()
|
||||
sid := j["session"].(map[string]interface{})["id"].(string)
|
||||
if j2 := h.poll(sid); j2["state"] != "pending" {
|
||||
return false
|
||||
} else if iv, _ := j2["interval"].(float64); iv != 2 {
|
||||
return false
|
||||
}
|
||||
// 行3: error=access_denied → failed
|
||||
if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" {
|
||||
return false
|
||||
}
|
||||
// 行4: 成功(access+refresh)→ register 换 CLINE token → completed
|
||||
if j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`); j["state"] != "completed" || j["account"] != "dev@example.com" {
|
||||
return false
|
||||
}
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:cline")
|
||||
if v != "CLINE-AT-1" {
|
||||
return false
|
||||
}
|
||||
// 行5: 非200无error字段 → failed(不是死等)
|
||||
if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestClineWorkOSBehavior:WorkOS 判据(error 字段)+ register 两步 + slow_down +1s。
|
||||
func TestClineWorkOSBehavior(t *testing.T) {
|
||||
h := newClineHarness(t)
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
|
||||
st := func(steps ...string) map[string]interface{} {
|
||||
h.tok.Store(scriptSteps(steps...))
|
||||
atomic.StoreInt32(&h.step, 0)
|
||||
j := h.begin()
|
||||
sid := j["session"].(map[string]interface{})["id"].(string)
|
||||
return h.poll(sid)
|
||||
}
|
||||
|
||||
t.Run("行1 error=authorization_pending_非200也继续等", func(t *testing.T) {
|
||||
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
|
||||
t.Fatalf("应 pending: %v", j)
|
||||
}
|
||||
})
|
||||
t.Run("行2 error=slow_down_退避1秒", func(t *testing.T) {
|
||||
h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`))
|
||||
atomic.StoreInt32(&h.step, 0)
|
||||
j := h.begin()
|
||||
sid := j["session"].(map[string]interface{})["id"].(string)
|
||||
j2 := h.poll(sid)
|
||||
if j2["state"] != "pending" {
|
||||
t.Fatalf("应 pending: %v", j2)
|
||||
}
|
||||
if iv, _ := j2["interval"].(float64); iv != 2 { // 基线 1 + slow_down_step 1
|
||||
t.Fatalf("WorkOS slow_down 应 interval+1s 得 2, 实得 %v", iv)
|
||||
}
|
||||
})
|
||||
t.Run("行3 error=access_denied_失败带拒绝", func(t *testing.T) {
|
||||
if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" {
|
||||
t.Fatalf("应 failed: %v", j)
|
||||
}
|
||||
})
|
||||
t.Run("行4 成功_access+refresh_再打register_凭据是CLINE-AT-1", func(t *testing.T) {
|
||||
j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`)
|
||||
if j["state"] != "completed" || j["account"] != "dev@example.com" {
|
||||
t.Fatalf("应 completed+dev@example.com: %v", j)
|
||||
}
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:cline")
|
||||
if v != "CLINE-AT-1" {
|
||||
t.Fatalf("凭据应是 register 的 CLINE-AT-1, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行5 非200无error字段_失败", func(t *testing.T) {
|
||||
if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" {
|
||||
t.Fatalf("应 failed: %v", j)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Buddy(CodeBuddy state 轮询) ----
|
||||
|
||||
type buddyHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
tokStep int32
|
||||
tokOk int32
|
||||
acctOk int32
|
||||
}
|
||||
|
||||
func newBuddyHarness(t *testing.T) *buddyHarness {
|
||||
t.Helper()
|
||||
h := &buddyHarness{t: t, tokOk: 1, acctOk: 1}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == "POST" && r.URL.Path == "/v2/plugin/auth/state":
|
||||
fmt.Fprint(w, `{"data":{"state":"ST-1","authUrl":"https://www.codebuddy.cn/login/?platform=ide&state=ST-1"}}`)
|
||||
case r.Method == "GET" && r.URL.Path == "/v2/plugin/auth/token":
|
||||
i := atomic.AddInt32(&h.tokStep, 1)
|
||||
if atomic.LoadInt32(&h.tokOk) == 0 {
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`)
|
||||
return
|
||||
}
|
||||
if i == 1 {
|
||||
fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"data":{"accessToken":"BD-AT-1","refreshToken":"BD-RT-1"}}`)
|
||||
}
|
||||
case r.Method == "GET" && r.URL.Path == "/v2/plugin/login/account":
|
||||
if atomic.LoadInt32(&h.acctOk) == 0 {
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprint(w, `{"code":1}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"data":{"nickName":"buddy-dev"}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, _ := readBundledChannels(t)
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"state": map[string]interface{}{"state_url": h.ts.URL + "/v2/plugin/auth/state", "token_url": h.ts.URL + "/v2/plugin/auth/token", "account_url": h.ts.URL + "/v2/plugin/login/account"},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *buddyHarness) begin() (map[string]interface{}, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。
|
||||
// 这正是用户报障("登录了但无法获取账号信息")的那条路径。
|
||||
func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) {
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string), aid
|
||||
}
|
||||
|
||||
func (h *buddyHarness) accountList() []interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
accts, _ := m["accounts"].([]interface{})
|
||||
return accts
|
||||
}
|
||||
func (h *buddyHarness) poll(id string) map[string]interface{} {
|
||||
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": id},
|
||||
})
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestBuddyStatePollBehavior:state-poll 两拍(先 11217 后 token)+ 账号查询 + account 失败不阻塞。
|
||||
func TestBuddyStatePollBehavior(t *testing.T) {
|
||||
h := newBuddyHarness(t)
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
|
||||
t.Run("行1 state申请给出authUrl", func(t *testing.T) {
|
||||
j, _ := h.begin()
|
||||
if j["url"] == "" {
|
||||
t.Fatalf("应有 authUrl: %v", j)
|
||||
}
|
||||
})
|
||||
t.Run("行2 首拍11217仍pending_次拍token_completed", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.tokStep, 0)
|
||||
_, sid := h.begin()
|
||||
if j := h.poll(sid); j["state"] != "pending" {
|
||||
t.Fatalf("首拍应 pending: %v", j)
|
||||
}
|
||||
if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" {
|
||||
t.Fatalf("次拍应 completed+buddy-dev: %v", j)
|
||||
}
|
||||
// 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token:
|
||||
// 必须带 refresh_token,否则 refreshable=false、无法静默续期。
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:buddy")
|
||||
if !strings.Contains(v, `"access_token":"BD-AT-1"`) {
|
||||
t.Fatalf("凭据应含 access_token, 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, "buddy-dev") {
|
||||
t.Fatalf("凭据应回填昵称, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.tokStep, 0)
|
||||
atomic.StoreInt32(&h.acctOk, 0)
|
||||
defer atomic.StoreInt32(&h.acctOk, 1)
|
||||
_, sid := h.begin()
|
||||
h.poll(sid)
|
||||
j := h.poll(sid)
|
||||
if j["state"] != "completed" {
|
||||
t.Fatalf("account 失败不应阻塞: %v", j)
|
||||
}
|
||||
})
|
||||
|
||||
// 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。
|
||||
// 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`,
|
||||
// 而池账号 credentialRef 是 `channels:buddy:<aid>` → account_list 报
|
||||
// has_credential=false、credits 报「无凭据」——即用户报障的
|
||||
// 「登录了但无法获取账号信息」。
|
||||
t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.tokStep, 0)
|
||||
_, sid, aid := h.beginPool()
|
||||
h.poll(sid) // 首拍 11217
|
||||
j := h.poll(sid)
|
||||
if j["state"] != "completed" {
|
||||
t.Fatalf("池路径登录应完成: %v", j)
|
||||
}
|
||||
// ① 凭据必须落在池账号的 ref 上
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("池凭据应是完整 JSON: %q", v)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true(UI 读的就是这个字段)
|
||||
found := false
|
||||
for _, raw := range h.accountList() {
|
||||
a, _ := raw.(map[string]interface{})
|
||||
if a["id"] == aid {
|
||||
found = true
|
||||
if a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
if a["refreshable"] != true {
|
||||
t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("池账号 %s 未出现在 account_list", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
func scriptSteps(steps ...string) []scriptStep {
|
||||
var out []scriptStep
|
||||
for _, b := range steps {
|
||||
code := 200
|
||||
switch {
|
||||
case strings.HasPrefix(b, "400 "):
|
||||
code, b = 400, b[4:]
|
||||
case strings.HasPrefix(b, "403 "):
|
||||
code, b = 403, b[4:]
|
||||
case strings.HasPrefix(b, "500 "):
|
||||
code, b = 500, b[4:]
|
||||
}
|
||||
out = append(out, scriptStep{http: code, body: b})
|
||||
}
|
||||
return out
|
||||
}
|
||||
82
internal/core/channels_orphan_callback_test.go
Normal file
82
internal/core/channels_orphan_callback_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。
|
||||
//
|
||||
// 真实事故:生产 `/etc/llmsproxy/plugins/` 攒了 **18 个 callback-*.port**
|
||||
// (对应的 .json 早已消失)。它们永远不会被清,因为:
|
||||
//
|
||||
// · loopbacks 追踪表只活在内存(plugin.state.loopbacks);
|
||||
// · 进程被强杀时 on_unload 没机会跑;
|
||||
// · 重启后内存表是空的 ⇒ reapLoopback 根本不知道它们存在。
|
||||
//
|
||||
// 判据看的是**目录的真实状态**(孤儿必须消失、活跃会话的文件不能被误删),
|
||||
// 而不是"某函数被调用过"。
|
||||
func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
|
||||
// 摆上一轮的残留(模拟上一进程被强杀后的现场)
|
||||
orphans := []string{
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.port"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.json"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0002.port"),
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if err := os.WriteFile(f, []byte("12345"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 一份**不属于**回调的文件:必须不被误删(否则清扫过头会毁掉账号池)
|
||||
keep := filepath.Join(pluginDir, ".channels-store.json")
|
||||
if err := os.WriteFile(keep, []byte(`{"accounts":{}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 加载插件 = 走真实启动路径
|
||||
c := kernelTestCore(t)
|
||||
defer c.Close()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 顶层清扫代码在加载期就跑了;给 popen 的 rm 一点时间落盘
|
||||
for i := 0; i < 50; i++ {
|
||||
left := 0
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
left++
|
||||
}
|
||||
}
|
||||
if left == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
t.Fatalf("孤儿回调文件未被清扫: %s —— 它会永久留在磁盘上"+
|
||||
"(真实事故:生产攒了 18 个)", filepath.Base(f))
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Fatalf("清扫过头了:%s 被误删(账号池/索引绝不能碰)", filepath.Base(keep))
|
||||
}
|
||||
t.Log("✓ 孤儿回调文件被清,非回调文件保留")
|
||||
}
|
||||
|
||||
var _ = lua.Plugins{}
|
||||
438
internal/core/channels_plugin_test.go
Normal file
438
internal/core/channels_plugin_test.go
Normal file
@ -0,0 +1,438 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsPluginEndToEnd 是「同类插件」的完整验收:
|
||||
// 内置的 channels 插件在**真实内核**上跑通「登录 → 存凭据 → 接入为上游源」。
|
||||
//
|
||||
// 这条链路横跨本次所有新增能力,所以它是最有信息量的一个测试:
|
||||
//
|
||||
// __llmsproxy_kernel add_source 把平台注册为上游源
|
||||
// 内核权限 默认拒绝,需运维授权
|
||||
// 额外出站主机授权 登录域不在源列表里,默认被出站白名单拒绝(plugin.hosts)
|
||||
// 登录会话 插件自持(plugin.state.sessions),内核无登录概念
|
||||
func TestChannelsPluginEndToEnd(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatalf("读取内置 channels 插件: %v", err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatalf("加载 channels: %v", err)
|
||||
}
|
||||
// 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码
|
||||
// (否则会出现在生产 UI 的平台列表里)。
|
||||
injectTestPlatforms(t, ps, examplePlatformFixture)
|
||||
|
||||
// 1. 平台清单可用(未授权任何东西也应能列出)
|
||||
out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list_platforms: %v", err)
|
||||
}
|
||||
body, _ := out.JSON.(map[string]interface{})
|
||||
plats, _ := body["platforms"].([]interface{})
|
||||
if len(plats) < 3 {
|
||||
t.Fatalf("应至少 3 个平台, 实得 %d", len(plats))
|
||||
}
|
||||
|
||||
// 2. 发起登录 → 拿到会话与 URL
|
||||
out, err = ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"example"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("发起登录失败: %v", m["error"])
|
||||
}
|
||||
if m["url"] == nil || m["url"] == "" {
|
||||
t.Fatal("应返回用户要打开的登录 URL")
|
||||
}
|
||||
sess := m["session"].(map[string]interface{})
|
||||
id, _ := sess["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("应返回会话 id")
|
||||
}
|
||||
|
||||
// 3. 轮询到 pending
|
||||
out, _ = ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": id}, Role: "admin",
|
||||
})
|
||||
if st := out.JSON.(map[string]interface{})["state"]; st != "pending" {
|
||||
t.Fatalf("应为 pending, 实得 %v", st)
|
||||
}
|
||||
|
||||
// 4. 未授权内核动作时,接入源必须失败且**如实报告**(不是假装成功)
|
||||
out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("complete_login: %v", err)
|
||||
}
|
||||
done := out.JSON.(map[string]interface{})
|
||||
if done["source_error"] == nil {
|
||||
t.Fatal("未授权 add_source 时应报告 source_error")
|
||||
}
|
||||
t.Logf("未授权时的如实报告: %v", done["source_error"])
|
||||
|
||||
// 凭据必须已在保险库(登录本身成功了)
|
||||
tok, ok, _ := ps.GetSecret("channels", "channels:example")
|
||||
if !ok || tok != "tok-1" {
|
||||
t.Fatalf("凭据应已存保险库: ok=%v tok=%q", ok, tok)
|
||||
}
|
||||
// 且不得出现在 state 里
|
||||
if stateHasSecret(ps, "channels", "tok-1") {
|
||||
t.Fatal("凭据泄漏进了 plugin.state")
|
||||
}
|
||||
|
||||
// 5. 运维授权后,重新接入应当真的把源注册进内核
|
||||
if err := ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "list_sources", "status"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("complete_login(授权后): %v", err)
|
||||
}
|
||||
done = out.JSON.(map[string]interface{})
|
||||
if done["source_error"] != nil {
|
||||
t.Fatalf("授权后仍失败: %v", done["source_error"])
|
||||
}
|
||||
if done["source"] != "channels-example" {
|
||||
t.Fatalf("源名不对: %v", done["source"])
|
||||
}
|
||||
|
||||
// 内核侧必须真的有这个源
|
||||
found := false
|
||||
for _, s := range c.Sources() {
|
||||
if s.Name == "channels-example" {
|
||||
found = true
|
||||
if s.BaseURL != "https://api.example.com/v1" {
|
||||
t.Errorf("base_url 未落库: %q", s.BaseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("插件接入的源没有出现在内核源列表里")
|
||||
}
|
||||
// 平台声明的模型应可路由
|
||||
models := c.Registry().ModelList()
|
||||
hasModel := false
|
||||
for _, id := range models {
|
||||
if id == "example-chat" {
|
||||
hasModel = true
|
||||
}
|
||||
}
|
||||
if !hasModel {
|
||||
t.Errorf("平台模型未进入可路由列表: %v", models)
|
||||
}
|
||||
t.Logf("已接入源 channels-example,模型可见: %v", models)
|
||||
}
|
||||
|
||||
// TestChannelsPluginDeclaresItsNeeds 断言插件如实声明了它需要什么。
|
||||
//
|
||||
// 声明是运维审批的依据:插件不声明,设置页就无法显示"申请中",
|
||||
// 运维只能等某个功能静默失效后才发现。
|
||||
func TestChannelsPluginDeclaresItsNeeds(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 内核动作声明
|
||||
views := ps.KernelPermissions()
|
||||
var declared []string
|
||||
for _, v := range views {
|
||||
if v.Plugin == "channels" {
|
||||
declared = v.Declared
|
||||
}
|
||||
}
|
||||
if len(declared) == 0 {
|
||||
t.Fatal("channels 应声明它需要的内核动作")
|
||||
}
|
||||
for _, want := range []string{"add_source", "status"} {
|
||||
found := false
|
||||
for _, d := range declared {
|
||||
if d == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("应声明 %q, 实得 %v", want, declared)
|
||||
}
|
||||
}
|
||||
// 额外出站主机声明(非源 host,需单独批准)
|
||||
hosts := ps.PluginExtraHosts("channels")
|
||||
if len(hosts) == 0 {
|
||||
t.Fatal("channels 应声明额外出站主机")
|
||||
}
|
||||
t.Logf("声明内核动作=%v 额外主机=%v", declared, hosts)
|
||||
}
|
||||
|
||||
// TestChannelsPluginHandlersRequireStatus 是一个契约回归:插件的每个 handler
|
||||
// 都必须返回 {status,json/body},否则内核会**静默丢弃**它(实测踩过:
|
||||
// `return {url=…}` 得到 200 空响应,没有任何错误信息)。
|
||||
func TestChannelsPluginHandlersRequireStatus(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
|
||||
// 每个 handler 至少要在正常输入下给出非空响应
|
||||
cases := []struct {
|
||||
fn string
|
||||
req *lua.HandlerRequest
|
||||
what string
|
||||
}{
|
||||
{"list_platforms", &lua.HandlerRequest{Method: "GET"}, "平台列表"},
|
||||
{"status_page", &lua.HandlerRequest{Method: "GET"}, "状态页"},
|
||||
{"poll_login", &lua.HandlerRequest{Method: "GET", Query: map[string]string{"id": "nope"}}, "轮询(未知会话)"},
|
||||
{"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "发起登录"},
|
||||
{"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"nope"}`}, "发起登录(未知平台)"},
|
||||
{"complete_login", &lua.HandlerRequest{Method: "POST", Body: `{}`}, "完成登录(缺字段)"},
|
||||
{"claim_daily", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "领额度(未登录)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
out, err := ps.InvokeHandler("channels", tc.fn, tc.req)
|
||||
if err != nil {
|
||||
t.Errorf("%s (%s) 返回错误: %v", tc.fn, tc.what, err)
|
||||
continue
|
||||
}
|
||||
if out.JSON == nil && out.Body == "" {
|
||||
t.Errorf("%s (%s) 返回空响应 —— 内核会静默丢弃,调用方看不到任何原因",
|
||||
tc.fn, tc.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonMarshalForTest 序列化一个接口值,用于检查凭据是否出现在 state 里。
|
||||
func jsonMarshalForTest(v interface{}) ([]byte, error) { return json.Marshal(v) }
|
||||
|
||||
// stateHasSecret 检查凭据是否泄漏进 plugin.state。
|
||||
func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool {
|
||||
st := ps.State(plugin)
|
||||
if st == nil {
|
||||
return false
|
||||
}
|
||||
b, err := jsonMarshalForTest(st)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return contains(string(b), secret)
|
||||
}
|
||||
|
||||
// examplePlatformFixture 是端到端测试用的占位平台(对应此前内联在生产源码里
|
||||
// 的 "example" 平台)。它经 inject_platforms 注入,且**只在测试进程内存在** ——
|
||||
// 生产插件的 PLATFORMS 只含真实厂商。
|
||||
const examplePlatformFixture = `[
|
||||
{
|
||||
"id": "example",
|
||||
"name": "Example 平台(测试夹具)",
|
||||
"flow": "link-poll",
|
||||
"login_url": "https://auth.example.com/device",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"adapter": "openai",
|
||||
"models": ["example-chat", "example-reason"],
|
||||
"checkin": {"url": "https://api.example.com/v1/api/daily/claim", "method": "POST", "body": "{}"}
|
||||
}
|
||||
]`
|
||||
|
||||
// injectTestPlatforms 把测试夹具注入插件(运行时,不落盘)。
|
||||
func injectTestPlatforms(t *testing.T, ps *lua.Plugins, fixtureJSON string) {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platforms":` + fixtureJSON + `}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inject_platforms: %v", err)
|
||||
}
|
||||
m, _ := out.JSON.(map[string]interface{})
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("注入测试平台失败: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectPlatformsRejectsBadInput 锁定注入机制的三条边界:
|
||||
// 1. 不得覆盖内建平台(注入不能劫持真实厂商)
|
||||
// 2. 重复 id 拒绝
|
||||
// 3. 空数组 = 清空注入(夹具不跨测试残留)
|
||||
//
|
||||
// 这个机制存在的理由:夹具内联进生产源码会让它们出现在生产 UI 的平台列表里
|
||||
// (真实踩过 —— 16 个平台里有 4 个是夹具)。
|
||||
func TestInjectPlatformsRejectsBadInput(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
call := func(body string) map[string]interface{} {
|
||||
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
|
||||
Method: "POST", Body: body, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inject_platforms(%s): %v", body, err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
platforms := func() []interface{} {
|
||||
out, _ := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
return out.JSON.(map[string]interface{})["platforms"].([]interface{})
|
||||
}
|
||||
|
||||
base := len(platforms())
|
||||
if base == 0 {
|
||||
t.Fatal("生产目录应有真实厂商平台")
|
||||
}
|
||||
|
||||
// 注入一个合法夹具 → 列表 +1
|
||||
if m := call(`{"platforms":[{"id":"fixture-a","name":"A","flow":"paste","base_url":"https://a.example/v1"}]}`); m["ok"] != true {
|
||||
t.Fatalf("合法注入应成功: %v", m)
|
||||
}
|
||||
if got := len(platforms()); got != base+1 {
|
||||
t.Fatalf("注入后平台数 = %d, 期望 %d", got, base+1)
|
||||
}
|
||||
|
||||
// 1. 覆盖内建平台 → 拒绝
|
||||
if m := call(`{"platforms":[{"id":"buddy","name":"劫持"}]}`); m["error"] == nil {
|
||||
t.Fatal("覆盖内建平台必须被拒绝")
|
||||
}
|
||||
// 2. 重复 id → 拒绝
|
||||
if m := call(`{"platforms":[{"id":"fixture-b"},{"id":"fixture-b"}]}`); m["error"] == nil {
|
||||
t.Fatal("重复 id 必须被拒绝")
|
||||
}
|
||||
// 无 id → 拒绝
|
||||
if m := call(`{"platforms":[{"name":"no-id"}]}`); m["error"] == nil {
|
||||
t.Fatal("缺 id 必须被拒绝")
|
||||
}
|
||||
|
||||
// 3. 空数组 = 清空注入,回到基线
|
||||
if m := call(`{"platforms":[]}`); m["ok"] != true {
|
||||
t.Fatalf("清空应成功: %v", m)
|
||||
}
|
||||
if got := len(platforms()); got != base {
|
||||
t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsBrowserStackDeps 锁定浏览器栈的依赖预检能力。
|
||||
//
|
||||
// 这个能力此前**不存在**:代码里写着「运行 deploy 脚本一键安装」,但安装机制
|
||||
// 根本没有(deploy.sh 里零浏览器栈内容),于是 VNC 兜底在缺依赖的机器上只会
|
||||
// 报一句含糊的"进程启动失败"。现在如实报告缺什么、装哪个包。
|
||||
func TestChannelsBrowserStackDeps(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deps_status: %v", err)
|
||||
}
|
||||
m, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("deps_status 应返回对象: %#v", out.JSON)
|
||||
}
|
||||
if _, has := m["ok"]; !has {
|
||||
t.Fatal("deps_status 必须报告 ok")
|
||||
}
|
||||
if _, has := m["missing"]; !has {
|
||||
t.Fatal("deps_status 必须报告 missing 清单")
|
||||
}
|
||||
if m["ok"] == false {
|
||||
hint, _ := m["install_hint"].(string)
|
||||
if hint == "" {
|
||||
t.Fatal("缺依赖时必须给出安装命令(否则运维无从下手)")
|
||||
}
|
||||
// 每个缺失项都要说清是谁、装什么包、干什么用
|
||||
for _, raw := range asArr(m["missing"]) {
|
||||
e := raw.(map[string]interface{})
|
||||
for _, k := range []string{"bin", "pkg", "why"} {
|
||||
if s, _ := e[k].(string); s == "" {
|
||||
t.Fatalf("缺少字段 %s: %v", k, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("本机缺依赖(预期:CI 容器通常没有浏览器栈): %v", m["missing"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsBrowserEndpointsRequireSession 浏览器栈的三个端点在没有会话时
|
||||
// 必须**如实报错**,而不是假装成功或 panic。
|
||||
func TestChannelsBrowserEndpointsRequireSession(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 无会话时 collect 必须报错
|
||||
out, err := ps.InvokeHandler("channels", "collect_credentials", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collect_credentials: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["error"] == nil {
|
||||
t.Fatal("无浏览器会话时 collect_credentials 必须报错")
|
||||
}
|
||||
|
||||
// status 如实说 running=false
|
||||
out, err = ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("browser_status: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["running"] != false {
|
||||
t.Fatal("无会话时 running 应为 false")
|
||||
}
|
||||
|
||||
// stop 幂等:无会话时也返回 ok
|
||||
out, err = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Fatal("stop_browser 应幂等成功")
|
||||
}
|
||||
}
|
||||
225
internal/core/channels_pool_reason_test.go
Normal file
225
internal/core/channels_pool_reason_test.go
Normal file
@ -0,0 +1,225 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。
|
||||
//
|
||||
// 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让
|
||||
// **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天,
|
||||
// 而号里其实有钱。两者的**正确动作完全相反**:
|
||||
//
|
||||
// exhausted → 等额度重置
|
||||
// unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
|
||||
//
|
||||
// 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。
|
||||
func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) {
|
||||
t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
h.serveBalance(0, 0)
|
||||
got := h.reason()
|
||||
if got["reason"] != "exhausted" {
|
||||
t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v",
|
||||
got["reason"], got["detail"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
h.serveBalanceError(500)
|
||||
got := h.reason()
|
||||
if got["reason"] != "unknown" {
|
||||
t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+
|
||||
"(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"])
|
||||
}
|
||||
if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") {
|
||||
t.Fatalf("说明必须点明是查询失败,实得 %q", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("没有启用的账号 → no_accounts", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
got := h.reason()
|
||||
if got["reason"] != "no_accounts" {
|
||||
t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("有可用账号 → usable", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
h.serveBalance(100, 0)
|
||||
got := h.reason()
|
||||
if got["reason"] != "usable" {
|
||||
t.Fatalf("有钱时应报 usable,实得 %v", got["reason"])
|
||||
}
|
||||
if u, _ := got["usable"].(float64); int(u) != 1 {
|
||||
t.Fatalf("usable 应为 1,实得 %v", got["usable"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
// ⚠ 必须有**启用的账号**:判定的优先级是
|
||||
// no_accounts → 无余额端点 → usable → unknown/exhausted
|
||||
// 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高,
|
||||
// 那是**正确**行为),把正确的实现判成了错。
|
||||
h.addAccountOn("minimax", "m1")
|
||||
// minimax 未声明 balance 端点
|
||||
got := h.reasonOn("minimax")
|
||||
if got["reason"] != "unknown" {
|
||||
t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v",
|
||||
got["reason"], got["detail"])
|
||||
}
|
||||
if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") {
|
||||
t.Fatalf("说明必须点明无法判定,实得 %q", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted
|
||||
// (用户动作不同:解锁 vs 等重置)。
|
||||
func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) {
|
||||
h := newPoolReasonHarness(t)
|
||||
h.addAccount("a1")
|
||||
// total_fn 取包列表:只有永久积分(到期未知)
|
||||
h.serveBalance(0, 50)
|
||||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||||
map[string]interface{}{"lock": true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := h.reason()
|
||||
if got["reason"] != "locked" {
|
||||
t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v",
|
||||
got["reason"], got["detail"])
|
||||
}
|
||||
// 解锁后同一份余额应为 usable(证明 locked 不是"用尽")
|
||||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||||
map[string]interface{}{"lock": false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.serveBalance(0, 50)
|
||||
if got2 := h.reason(); got2["reason"] != "usable" {
|
||||
t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type poolReasonHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
body atomic.Value
|
||||
fail atomic.Bool
|
||||
hasAcct atomic.Bool
|
||||
}
|
||||
|
||||
func newPoolReasonHarness(t *testing.T) *poolReasonHarness {
|
||||
t.Helper()
|
||||
h := &poolReasonHarness{t: t}
|
||||
// buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts
|
||||
h.body.Store(buddyPkgBody(0, 0))
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if h.fail.Load() {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, h.body.Load().(string))
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.ps = ps
|
||||
// buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"balance": map[string]interface{}{"url": h.ts.URL},
|
||||
}); err != nil {
|
||||
t.Logf("set_endpoints(balance) 未支持: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
h.addAccountOn("buddy", id)
|
||||
}
|
||||
|
||||
// addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致
|
||||
// "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的
|
||||
// no_accounts —— 判据写错而非实现错)。
|
||||
func (h *poolReasonHarness) addAccountOn(platform, id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": platform, "account_id": id,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", platform, err)
|
||||
}
|
||||
h.hasAcct.Store(true)
|
||||
}
|
||||
|
||||
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
|
||||
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
|
||||
h.fail.Store(false)
|
||||
h.body.Store(buddyPkgBody(expiring, permanent))
|
||||
}
|
||||
|
||||
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
|
||||
// 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain /
|
||||
// DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。
|
||||
func buddyPkgBody(expiring, permanent int) string {
|
||||
pkgs := []string{}
|
||||
if expiring > 0 {
|
||||
// 3 天到期(< 15 天窗口)
|
||||
endMs := (time.Now().Unix() + 3*86400) * 1000
|
||||
pkgs = append(pkgs, fmt.Sprintf(
|
||||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs))
|
||||
}
|
||||
if permanent > 0 {
|
||||
// DeductionEndTime=0(未知)⇒ 永久桶
|
||||
pkgs = append(pkgs, fmt.Sprintf(
|
||||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent))
|
||||
}
|
||||
return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}`
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) serveBalanceError(code int) {
|
||||
h.fail.Store(true)
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) reason() map[string]interface{} {
|
||||
h.t.Helper()
|
||||
return h.reasonOn("buddy")
|
||||
}
|
||||
|
||||
func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "credits_pool_reason",
|
||||
map[string]interface{}{"platform": platform})
|
||||
if err != nil {
|
||||
h.t.Fatalf("credits_pool_reason: %v", err)
|
||||
}
|
||||
return jsonOf(h.t, res)
|
||||
}
|
||||
439
internal/core/channels_pool_test.go
Normal file
439
internal/core/channels_pool_test.go
Normal file
@ -0,0 +1,439 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
|
||||
// 账号池增删改、上游源随 enabled/模型开关同步、供应商整体开关、备份导出。
|
||||
|
||||
func poolCore(t *testing.T) (*Core, *lua.Plugins) {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "remove_source", "status", "probe_all", "reset_health"}, false)
|
||||
return c, ps
|
||||
}
|
||||
|
||||
// listSourcesViaKernel 直查内核 list_sources(actionListSources 返回 []map)。
|
||||
func listSourcesViaKernel(t *testing.T, c *Core) []map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := c.Invoke(nil, "list_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("list_sources: %v", err)
|
||||
}
|
||||
if ms, ok := out.([]map[string]interface{}); ok {
|
||||
return ms
|
||||
}
|
||||
raw, _ := out.([]interface{})
|
||||
var srcs []map[string]interface{}
|
||||
for _, s := range raw {
|
||||
if m, ok := s.(map[string]interface{}); ok {
|
||||
srcs = append(srcs, m)
|
||||
}
|
||||
}
|
||||
return srcs
|
||||
}
|
||||
|
||||
func invoke(t *testing.T, ps *lua.Plugins, fn, body string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", fn, &lua.HandlerRequest{Method: "POST", Body: body})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke %s: %v", fn, err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// asArr 规整桥接后的数组。Lua 空表过桥是 map[string]interface{}(无法与空数组
|
||||
// 区分,见 vm.go luaTableToGoValue);内核 list_sources 的 models 过桥是 []string。
|
||||
func asArr(v interface{}) []interface{} {
|
||||
switch a := v.(type) {
|
||||
case []interface{}:
|
||||
return a
|
||||
case []string:
|
||||
out := make([]interface{}, len(a))
|
||||
for i, s := range a {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func srcHasAccount(srcs []map[string]interface{}, needle string) (map[string]interface{}, bool) {
|
||||
for _, sm := range srcs {
|
||||
if name, _ := sm["name"].(string); strings.Contains(name, needle) {
|
||||
return sm, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TestChannelsAccountPoolBehavior:账号池 CRUD + 上游源同步 + 模型/供应商开关 + 备份。
|
||||
func TestChannelsAccountPoolBehavior(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
|
||||
t.Run("账号增删改", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"我的号"}`)
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("account_add: %v", m)
|
||||
}
|
||||
aid, _ := m["account_id"].(string)
|
||||
if aid == "" {
|
||||
t.Fatalf("account_add 应返回 account_id: %v", m)
|
||||
}
|
||||
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
accts := asArr(m["accounts"])
|
||||
if len(accts) != 1 {
|
||||
t.Fatalf("应有1个账号: %d", len(accts))
|
||||
}
|
||||
a := accts[0].(map[string]interface{})
|
||||
if a["nickname"] != "我的号" || a["enabled"] != true {
|
||||
t.Fatalf("账号字段: %v", a)
|
||||
}
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"nickname":"改名"}}`)
|
||||
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
if asArr(m["accounts"])[0].(map[string]interface{})["nickname"] != "改名" {
|
||||
t.Fatalf("改名失败")
|
||||
}
|
||||
invoke(t, ps, "account_delete", `{"platform":"minimax","account_id":"`+aid+`"}`)
|
||||
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
if n := len(asArr(m["accounts"])); n != 0 {
|
||||
t.Fatalf("删除后应为0, 实得 %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("模型开关同步上游源", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`)
|
||||
aid, _ := m["account_id"].(string)
|
||||
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
if !ok {
|
||||
t.Fatal("带凭据账号应已同步源")
|
||||
}
|
||||
// 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐
|
||||
// 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**:
|
||||
// 关掉一个模型,源里就少一个。
|
||||
all := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
|
||||
total := len(asArr(all["models"]))
|
||||
if n := len(asArr(sm["models"])); n != total {
|
||||
t.Fatalf("源应含全部 %d 个模型, 实得 %d", total, n)
|
||||
}
|
||||
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
|
||||
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
if n := len(asArr(sm["models"])); n != total-1 {
|
||||
t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n)
|
||||
}
|
||||
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`)
|
||||
m = invoke(t, ps, "provider_status", `{}`)
|
||||
for _, s := range asArr(m["statuses"]) {
|
||||
x := s.(map[string]interface{})
|
||||
if x["id"] == "minimax" && x["closed"] != true {
|
||||
t.Fatalf("全关模型后应 closed=true: %v", x)
|
||||
}
|
||||
}
|
||||
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":false}`)
|
||||
})
|
||||
|
||||
t.Run("enabled开关同步上游源", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
accts := asArr(m["accounts"])
|
||||
if len(accts) == 0 {
|
||||
t.Fatal("上个用例应留有账号")
|
||||
}
|
||||
aid := accts[0].(map[string]interface{})["id"].(string)
|
||||
before := len(listSourcesViaKernel(t, c))
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`)
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before-1 {
|
||||
t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n)
|
||||
}
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`)
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before {
|
||||
t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("供应商整体开关", func(t *testing.T) {
|
||||
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":false}`)
|
||||
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); ok {
|
||||
t.Fatal("关闭渠道后不应有 minimax 源")
|
||||
}
|
||||
m := invoke(t, ps, "provider_status", `{}`)
|
||||
for _, s := range asArr(m["statuses"]) {
|
||||
x := s.(map[string]interface{})
|
||||
if x["id"] == "minimax" && x["closed"] != true {
|
||||
t.Fatalf("关闭后应 closed: %v", x)
|
||||
}
|
||||
}
|
||||
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":true}`)
|
||||
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok {
|
||||
t.Fatal("开启渠道后应有 minimax 源")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("备份导出含凭据", func(t *testing.T) {
|
||||
invoke(t, ps, "account_add", `{"platform":"cline","credential":"CL-KEY","nickname":"cl1"}`)
|
||||
exp := invoke(t, ps, "backup_export", `{}`)
|
||||
payload, _ := exp["payload"].(map[string]interface{})
|
||||
if payload == nil {
|
||||
t.Fatal("backup_export 应返回 payload")
|
||||
}
|
||||
creds, _ := payload["credentials"].(map[string]interface{})
|
||||
found := false
|
||||
for _, v := range creds {
|
||||
if v == "CL-KEY" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("备份应含凭据明文: %v", creds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestChannelsExtendedRPCBehavior:对齐参考剩余 RPC 面(credits.status/balances、
|
||||
// account.reset/retest、model.setDisabledMany/clearDead、provider 排序、
|
||||
// gateway/backup.status、usage.badgePreference、onboarding)。
|
||||
func TestChannelsExtendedRPCBehavior(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
t.Run("credits_balances_无凭据报错_有凭据解析", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"b1"}`)
|
||||
aid, _ := m["account_id"].(string)
|
||||
j := invoke(t, ps, "credits_balances", `{"platform":"minimax"}`)
|
||||
var row map[string]interface{}
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
x := a.(map[string]interface{})
|
||||
if x["accountId"] == aid {
|
||||
row = x
|
||||
}
|
||||
}
|
||||
if row == nil {
|
||||
t.Fatal("balances 应含该账号")
|
||||
}
|
||||
if row["balance"] != nil || row["error"] == nil {
|
||||
t.Fatalf("无凭据应 error 而非编造余额: %v", row)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credits_status_无端点平台如实null", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_add", `{"platform":"opencode","nickname":"oc1"}`)
|
||||
aid, _ := m["account_id"].(string)
|
||||
j := invoke(t, ps, "credits_status", `{"platform":"opencode"}`)
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
x := a.(map[string]interface{})
|
||||
if x["accountId"] == aid && x["status"] != nil {
|
||||
t.Fatalf("opencode 无状态端点应 null: %v", x)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("account_reset_clears", func(t *testing.T) {
|
||||
invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"r1"}`)
|
||||
j := invoke(t, ps, "account_reset", `{"platform":"minimax","account_id":"__all__"}`)
|
||||
if j["ok"] != true {
|
||||
t.Fatalf("resetAll: %v", j)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("model_set_disabled_many", func(t *testing.T) {
|
||||
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":true}`)
|
||||
j := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
|
||||
off := 0
|
||||
for _, x := range asArr(j["models"]) {
|
||||
mm := x.(map[string]interface{})
|
||||
if mm["disabled"] == true {
|
||||
off++
|
||||
}
|
||||
}
|
||||
if off != 2 {
|
||||
t.Fatalf("批量关2模型后应2个disabled, 实得 %d", off)
|
||||
}
|
||||
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`)
|
||||
})
|
||||
|
||||
// model_clear_dead 的语义已按参考修正:它清的是**有阳性 404 证据**的
|
||||
// "已失效模型"记录,**不是**"被禁用且不在目录里"的条目。
|
||||
//
|
||||
// 旧断言测的是后者(拿远端目录反推下架)—— 那个判据在远端目录残缺的平台上
|
||||
// 会删掉可用模型(参考文件头有实测教训),已弃用。
|
||||
// 新语义的完整判据在 channels_dead_model_test.go。
|
||||
t.Run("model_clear_dead_清失效记录而非拿目录反推", func(t *testing.T) {
|
||||
// 没有失效记录时:removed=0,且文案要说明"无记录"(而不是假装清了什么)
|
||||
j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
|
||||
if j["ok"] != true {
|
||||
t.Fatalf("model_clear_dead 应成功: %v", j)
|
||||
}
|
||||
if n, _ := j["removed"].(float64); n != 0 {
|
||||
t.Fatalf("无失效记录时应为 0, 实得 %v", j["removed"])
|
||||
}
|
||||
// 黑名单(disabled)与失效记录是两回事:清失效**不得**动 disabled
|
||||
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
|
||||
invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
|
||||
ml := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
|
||||
off := 0
|
||||
for _, m := range asArr(ml["models"]) {
|
||||
if mm, ok := m.(map[string]interface{}); ok && mm["disabled"] == true {
|
||||
off++
|
||||
}
|
||||
}
|
||||
if off != 1 {
|
||||
t.Fatalf("清失效记录不该动 disabled 黑名单, 仍应为 1, 实得 %d", off)
|
||||
}
|
||||
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`)
|
||||
})
|
||||
|
||||
t.Run("provider_order", func(t *testing.T) {
|
||||
invoke(t, ps, "provider_set_order", `{"order":["minimax","trae","bogus"]}`)
|
||||
j := invoke(t, ps, "provider_get_order", `{}`)
|
||||
order := asArr(j["order"])
|
||||
if len(order) != 2 {
|
||||
t.Fatalf("bogus 应被过滤, 实得 %v", order)
|
||||
}
|
||||
if order[0] != "minimax" {
|
||||
t.Fatalf("顺序应保留: %v", order)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup_status", func(t *testing.T) {
|
||||
invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"s1"}`)
|
||||
j := invoke(t, ps, "backup_status", `{}`)
|
||||
if n, _ := j["accounts"].(float64); n < 1 {
|
||||
t.Fatalf("backup_status 应统计账号: %v", j)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("badge_preference_非法值拒绝", func(t *testing.T) {
|
||||
j := invoke(t, ps, "usage_badge_preference", `{"preference":"bogus"}`)
|
||||
if j["error"] == nil {
|
||||
t.Fatalf("非法偏好应拒绝: %v", j)
|
||||
}
|
||||
j = invoke(t, ps, "usage_badge_preference", `{"preference":"credits"}`)
|
||||
if j["preference"] != "credits" {
|
||||
t.Fatalf("合法偏好应保存: %v", j)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("onboarding_不支持平台如实报", func(t *testing.T) {
|
||||
j := invoke(t, ps, "onboarding_status", `{"platform":"minimax"}`)
|
||||
if j["supported"] != false {
|
||||
t.Fatalf("minimax 无新人任务应 supported=false: %v", j)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permanent_lock_读写", func(t *testing.T) {
|
||||
j := invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy","locked":true}`)
|
||||
if j["locked"] != true {
|
||||
t.Fatalf("锁定应生效: %v", j)
|
||||
}
|
||||
j = invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy"}`)
|
||||
if j["locked"] != true {
|
||||
t.Fatalf("只读应回显锁定: %v", j)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考
|
||||
// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后
|
||||
// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。
|
||||
func TestBuddyBillingHeadersMatchReference(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
var got map[string]string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = map[string]string{}
|
||||
for k := range r.Header {
|
||||
got[k] = r.Header.Get(k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":{"totalCredit":42}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// 把 buddy 的余额/签到端点指到本地假上游
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("account_add: %v", m)
|
||||
}
|
||||
invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("余额请求未发出")
|
||||
}
|
||||
// 参考 checkinHeaders 的必备身份头(缺一个上游就 401)
|
||||
want := map[string]string{
|
||||
"Authorization": "Bearer BD-AT",
|
||||
"X-Domain": "copilot.tencent.com",
|
||||
"X-Product": "SaaS",
|
||||
"X-Product-Code": "codebuddy",
|
||||
"User-Agent": "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径:
|
||||
// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同,
|
||||
// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain ——
|
||||
// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。
|
||||
func TestBuddyBalanceParseDoubleNested(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// 两个有效包 + 一个失效包(ExpiredTime 在 2020 年)
|
||||
body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[
|
||||
{"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"}
|
||||
]}}}}`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
|
||||
j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
var row map[string]interface{}
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
bal, _ := row["balance"].(map[string]interface{})
|
||||
if bal == nil {
|
||||
t.Fatalf("应解析出余额, 实得 error=%v", row["error"])
|
||||
}
|
||||
// 100.5 + 55.17 = 155.67(失效包的 500 不计入)
|
||||
if got, _ := bal["total"].(float64); got != 155.67 {
|
||||
t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"])
|
||||
}
|
||||
|
||||
// 单层 data(缺 Response 层)必须判为解析失败,不能当 0
|
||||
body = `{"code":0,"data":{"TotalDosage":609}}`
|
||||
j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
if row["balance"] != nil {
|
||||
t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row)
|
||||
}
|
||||
}
|
||||
86
internal/core/channels_private_adapter_wiring_test.go
Normal file
86
internal/core/channels_private_adapter_wiring_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 三个"私有协议"平台的源注册必须引用**插件自装**的适配器。
|
||||
//
|
||||
// 为什么单独测这一条:适配器装上了 ≠ 源用了它。"装了但源还指向 openai"
|
||||
// 是这类改动最可能的漏配,且**不会报错** —— 只是签名/信封不生效、上游返回
|
||||
// 流内错误或 401,看起来像"平台不稳定"。
|
||||
//
|
||||
// 三个平台的失败模式各不相同,但根因是同一个(平台协议差异被当成适配器名差异):
|
||||
//
|
||||
// codearts → 华为云 AK/SK 签名
|
||||
// trae → SOLO 载荷格式(config_name/function)
|
||||
// gemini → 私有 /v1internal: 信封 + 身份五头
|
||||
func TestPrivateProtocolSourcesUsePluginAdapters(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
|
||||
cases := []struct {
|
||||
platform string
|
||||
wantAdapter string
|
||||
wantBaseHas string
|
||||
credential string
|
||||
}{
|
||||
{"codearts", "codearts",
|
||||
"snap-access.cn-north-4.myhuaweicloud.com",
|
||||
`{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`},
|
||||
{"trae", "channels-trae",
|
||||
"trae-api-cn.mchost.guru",
|
||||
`{"access_token":"TRAE-TOK","refresh_token":"RT"}`},
|
||||
{"gemini", "channels-gemini",
|
||||
"cloudcode-pa.googleapis.com",
|
||||
`{"access_token":"GEM-TOK","refresh_token":"GRT"}`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.platform, func(t *testing.T) {
|
||||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": tc.platform, "credential": tc.credential,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 报错: %#v", m)
|
||||
}
|
||||
aid, _ := j["account_id"].(string)
|
||||
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
if !ok {
|
||||
names := []string{}
|
||||
for _, x := range srcs {
|
||||
names = append(names, x["name"].(string))
|
||||
}
|
||||
t.Fatalf("%s 账号加了却不出源(现有源: %v)—— 源注册失败通常是"+
|
||||
"凭据形状不符或内核权限未授", tc.platform, names)
|
||||
}
|
||||
ad, _ := sm["adapter"].(string)
|
||||
if ad != tc.wantAdapter {
|
||||
t.Errorf("源 adapter = %q,应为插件自装的 %q"+
|
||||
"(否则平台协议转换不生效,且不报错)", ad, tc.wantAdapter)
|
||||
}
|
||||
bu, _ := sm["base_url"].(string)
|
||||
if !strings.Contains(bu, tc.wantBaseHas) {
|
||||
t.Errorf("base_url = %q,应含 %q", bu, tc.wantBaseHas)
|
||||
}
|
||||
// 模型必须进源(否则账号看起来接入了但一个模型都调不到)
|
||||
var nModels int
|
||||
switch mv := sm["models"].(type) {
|
||||
case []string:
|
||||
nModels = len(mv)
|
||||
case []interface{}:
|
||||
nModels = len(mv)
|
||||
}
|
||||
if nModels == 0 {
|
||||
t.Errorf("%s 源没有任何模型(%T)", tc.platform, sm["models"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
88
internal/core/channels_qoder_adapter_test.go
Normal file
88
internal/core/channels_qoder_adapter_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// qoderReason 调真实的 transform_error 钩子(不自己重算)。
|
||||
func qoderReason(t *testing.T, vm *lua.VM, status int, body string) string {
|
||||
t.Helper()
|
||||
reason, ok, err := vm.TransformError("channels-qoder", status, body)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_error 调用失败: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("channels-qoder 适配器未装载或未定义 transform_error(body=%s)", body)
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
// Qoder 的错误信封是**双层编码**的,且两个业务码语义**相反**:
|
||||
//
|
||||
// 10605 排队 → 暂时受阻,等一下就好
|
||||
// 110 额度 → 当天耗尽,重试无用(参考实测:原先归成通用服务端错误 → 白重试 5 次)
|
||||
//
|
||||
// 通用 openai 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` ——
|
||||
// 用户看不懂是排队还是额度用完。故用**插件自装的 channels-qoder**,
|
||||
// 厂商码值留在插件域(内核零厂商名)。
|
||||
func TestQoderAdapterParsesQueueAndBilling(t *testing.T) {
|
||||
c, _ := poolCore(t)
|
||||
vm := c.VM()
|
||||
|
||||
// ① 排队:外层 code=10605 + 内层 JSON 字符串(参考实测的真实形状)
|
||||
out := qoderReason(t, vm, 403,
|
||||
`{"code":"10605","message":"{\"isQueued\":true,\"queueCount\":12,\"retryAfterSeconds\":30}"}`)
|
||||
if !strings.Contains(out, "排队") {
|
||||
t.Errorf("10605 必须说成排队,实得 %q", out)
|
||||
}
|
||||
// 等待时长与队列长度要从**内层**取到(只读顶层永远拿不到)
|
||||
if !strings.Contains(out, "30") {
|
||||
t.Errorf("应从内层取到 retryAfterSeconds=30,实得 %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "12") {
|
||||
t.Errorf("应从内层取到 queueCount=12,实得 %q", out)
|
||||
}
|
||||
|
||||
// ② SSE 错误帧形态:**内层没有 code 字段**,只有排队标志。
|
||||
// ⚠️ 参考第一版要求「必须命中 code 才算排队」,导致这一形态被判成非排队、
|
||||
// SSE 通道的修复静默失效(探针显示 sleep=0)。这里必须能识别。
|
||||
out2 := qoderReason(t, vm, 200,
|
||||
`{"message":"{\"isQueued\":true,\"queueCount\":3,\"retryAfterSeconds\":8}"}`)
|
||||
if !strings.Contains(out2, "排队") {
|
||||
t.Errorf("内层无 code 也必须识别为排队(这是参考踩过的坑),实得 %q", out2)
|
||||
}
|
||||
if !strings.Contains(out2, "8") {
|
||||
t.Errorf("应取到内层 retryAfterSeconds=8,实得 %q", out2)
|
||||
}
|
||||
|
||||
// ③ 额度耗尽:必须与排队**分开说**,且明确"重试无用"
|
||||
out3 := qoderReason(t, vm, 403, `{"code":"110","message":"Billing daily count exceeded"}`)
|
||||
if !strings.Contains(out3, "额度") {
|
||||
t.Errorf("110 必须说成额度耗尽,实得 %q", out3)
|
||||
}
|
||||
if strings.Contains(out3, "排队") {
|
||||
t.Errorf("110 绝不能被说成排队(两者语义相反),实得 %q", out3)
|
||||
}
|
||||
if !strings.Contains(out3, "重试无用") {
|
||||
t.Errorf("额度耗尽要明确告知重试无用,实得 %q", out3)
|
||||
}
|
||||
|
||||
// ④ 泛词不能误伤:正文里讨论"配额/余额"不该被当成额度错误
|
||||
//(关键词必须窄 —— 宽词会把模型正常输出误判成故障)
|
||||
out4 := qoderReason(t, vm, 500,
|
||||
`{"error":{"message":"the response discusses quota and balance concepts"}}`)
|
||||
if strings.Contains(out4, "额度已用尽") {
|
||||
t.Errorf("泛词 quota/balance 不得误伤(关键词必须窄),实得 %q", out4)
|
||||
}
|
||||
|
||||
// ⑤ 与通用 openai 适配器对比:证明**通用器确实做不到**这件事
|
||||
//(否则这个专用适配器就没有存在理由)
|
||||
gen, okGen, _ := vm.TransformError("openai", 403,
|
||||
`{"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}`)
|
||||
if okGen && strings.Contains(gen, "排队") {
|
||||
t.Error("通用 openai 适配器不该能识别排队 —— 若能,则专用适配器无必要")
|
||||
}
|
||||
}
|
||||
222
internal/core/channels_qoder_claim_test.go
Normal file
222
internal/core/channels_qoder_claim_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// Qoder 活动领取有三条参考用**抓包 + 用户报障**定位过的「会误报」陷阱,
|
||||
// 全部必须在判据里钉死:
|
||||
//
|
||||
// ① 「无可领活动」必须是 inactive,**不能**报 already-claimed
|
||||
// (用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」);
|
||||
// ② 刷新前(每日 10:00 UTC+8 之前)看到 CLAIMED 属于**昨天** → 必须报 inactive
|
||||
// 而不是"今天已领取"(谎报且不可逆);
|
||||
// ③ 未开通要给出 action_required 的可操作提示。
|
||||
//
|
||||
// 另:`replayed:true` 是幂等判据(HTTP 仍是 200),只看状态码会把"今天已领"
|
||||
// 误报成"成功 +100"。
|
||||
func TestQoderClaimDecisionTable(t *testing.T) {
|
||||
type c struct {
|
||||
name string
|
||||
campaigns string
|
||||
usage string
|
||||
wantKind string
|
||||
wantAction bool
|
||||
wantClaimed int
|
||||
}
|
||||
cases := []c{
|
||||
{
|
||||
"无可领且无 CLAIM_BENEFIT,addOnQuota 缺失→inactive+可操作提示",
|
||||
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"VIEW_DETAILS","claimStatus":"CLAIMABLE"}]}`,
|
||||
`{"data":{"userQuota":{"remaining":0}}}`,
|
||||
"inactive", true, 0,
|
||||
},
|
||||
{
|
||||
"无可领但确实领过(CLAIMED)→ 视刷新时刻",
|
||||
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMED"}]}`,
|
||||
`{"data":{"addOnQuota":{"remaining":0}}}`,
|
||||
"", false, 0, // 见下方:按当前 UTC+8 时刻断言二者之一
|
||||
},
|
||||
{
|
||||
"可领→success(多个活动逐个领)",
|
||||
`{"showCampaign":true,"claimable":true,"campaigns":[
|
||||
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}},
|
||||
{"campaignId":"C2","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":50}}]}`,
|
||||
"",
|
||||
"success", false, 2,
|
||||
},
|
||||
{
|
||||
"可领但服务端回放 replayed→already-claimed(不是 success)",
|
||||
`{"showCampaign":true,"claimable":true,"campaigns":[
|
||||
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`,
|
||||
"",
|
||||
"already-claimed", false, 1,
|
||||
},
|
||||
{
|
||||
"无任何活动且已开通(addOnQuota 存在)→inactive(不带可操作提示)",
|
||||
`{"showCampaign":false,"claimable":false,"campaigns":[]}`,
|
||||
`{"data":{"addOnQuota":{"total":100,"remaining":0}}}`,
|
||||
"inactive", false, 0,
|
||||
},
|
||||
{
|
||||
"有 CLAIM_BENEFIT 但已过期→inactive",
|
||||
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"EXPIRED"}]}`,
|
||||
`{"data":{"addOnQuota":{"remaining":0}}}`,
|
||||
"inactive", false, 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claimed := 0
|
||||
var claimBody string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/sash/api/v1/me/campaigns":
|
||||
fmt.Fprint(w, tc.campaigns)
|
||||
case r.URL.Path == "/sash/api/v2/me/usage":
|
||||
if tc.usage == "" {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, tc.usage)
|
||||
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
|
||||
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
|
||||
claimed++
|
||||
b := make([]byte, 0)
|
||||
if r.ContentLength > 0 {
|
||||
b = make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(b)
|
||||
}
|
||||
claimBody = string(b)
|
||||
if tc.name == "可领但服务端回放 replayed→already-claimed(不是 success)" {
|
||||
fmt.Fprint(w, `{"replayed":true,"status":"CLAIMED"}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "qoder",
|
||||
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"platform": "qoder", "credential": string(cred), "account": "t",
|
||||
})
|
||||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: string(body),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim_daily: %v", err)
|
||||
}
|
||||
j, _ := out.JSON.(map[string]interface{})
|
||||
oc, _ := j["outcome"].(map[string]interface{})
|
||||
if oc == nil {
|
||||
t.Fatalf("应返回 outcome,实得 %v", j)
|
||||
}
|
||||
kind, _ := oc["kind"].(string)
|
||||
|
||||
if tc.wantKind == "" {
|
||||
// 刷新时刻决定 already-claimed 还是 inactive —— 两者都合法,
|
||||
// 但**绝不能**是 success(今天是否领过不能靠"看到 CLAIMED"推断成可以再领)。
|
||||
if kind != "already-claimed" && kind != "inactive" {
|
||||
t.Fatalf("刷新前/后都应报 already-claimed 或 inactive,实得 %q(%v)", kind, oc["message"])
|
||||
}
|
||||
} else if kind != tc.wantKind {
|
||||
t.Fatalf("kind 应为 %s,实得 %q(%v)", tc.wantKind, kind, oc["message"])
|
||||
}
|
||||
if tc.wantAction && oc["action_required"] != true {
|
||||
t.Errorf("未开通场景应带 action_required=true(提示去官方客户端),实得 %v", oc["action_required"])
|
||||
}
|
||||
if claimed != tc.wantClaimed {
|
||||
t.Errorf("claim 请求次数 = %d,期望 %d", claimed, tc.wantClaimed)
|
||||
}
|
||||
if tc.wantClaimed > 0 && claimBody != "" {
|
||||
t.Errorf("claim 请求体必须是空串(抓包实测 content-length: 0),实得 %q", claimBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 「今天的活动列表是否已刷新」必须用 **UTC+8 算术平移**判定。
|
||||
// ⚠️ 本机时区恰是 HKT(+0800),所以「用本机时区判」的实现在真实时刻下**结果相同**
|
||||
// —— 那是判据不可达(这台机器上永远抓不到)。必须注入 UTC 时刻来覆盖两个方向:
|
||||
// 判据的输入是 UTC 毫秒,与运行环境时区无关。
|
||||
func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// UTC 01:00 = UTC+8 09:00 → **未刷新**(阈值 10:00)
|
||||
// UTC 02:00 = UTC+8 10:00 → **已刷新**
|
||||
// 若实现误用本机时区(HKT=UTC+8),对"UTC 毫秒"这个输入会得到:
|
||||
// 把 07:00Z 当本地 15:00 → 判已刷新(错)
|
||||
cases := []struct {
|
||||
name string
|
||||
utcHour int
|
||||
want bool
|
||||
}{
|
||||
{"UTC 01:00(UTC+8 09:00,未到 10:00)", 1, false},
|
||||
{"UTC 02:00(UTC+8 10:00,刚好刷新)", 2, true},
|
||||
{"UTC 00:30(UTC+8 08:30,未刷新)", 0, false},
|
||||
{"UTC 15:00(UTC+8 23:00,已刷新)", 15, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 构造该 UTC 时刻的毫秒值(固定日期,避免跨日干扰)
|
||||
// 用 time.Date 明确构造「00:00 UTC + N 小时」——手算 epoch 会引入
|
||||
// 时区/跨日误差(第一版就是这么错的:基准不是 UTC 零点)。
|
||||
ms := time.Date(2025, 10, 9, tc.utcHour, 0, 0, 0, time.UTC).UnixMilli()
|
||||
var rec map[string]interface{}
|
||||
got, err := ps.Call("channels", "__qoder_refresh_probe", map[string]interface{}{
|
||||
"now_ms": float64(ms),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
rec, _ = w["json"].(map[string]interface{})
|
||||
if rec == nil {
|
||||
t.Fatalf("无 json: %#v", got)
|
||||
}
|
||||
if rec["refreshed"] != tc.want {
|
||||
t.Fatalf("refreshed 应为 %v,实得 %v(UTC+8 阈值 10:00)", tc.want, rec["refreshed"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
internal/core/channels_qoder_machine_test.go
Normal file
84
internal/core/channels_qoder_machine_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Qoder 的设备身份两个头**必须成对**出现:参考用抓包 + 消融实验定位过 ——
|
||||
// 缺任一个,服务端就不下发「可领取」的活动(只回 VIEW_DETAILS),
|
||||
// 症状是用户报的「没领过却显示今日已领取」。
|
||||
// 同时:拿不到身份时必须**保守降级**(照常发请求、不带这两个头),
|
||||
// 不能让纯插件登录(未装桌面端)的账号整个积分功能报错。
|
||||
func TestQoderMachineIdentityPairingAndDegradation(t *testing.T) {
|
||||
t.Run("行1 有 machine_token.json → 成对带上两个头", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "machine_token.json")
|
||||
if err := os.WriteFile(p, []byte(`{"token":"T-abc","type":"TY-def","updateAt":1}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("QODER_MACHINE_TOKEN_PATH", p)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("无 json: %#v", got)
|
||||
}
|
||||
if m["token"] != "T-abc" || m["type"] != "TY-def" {
|
||||
t.Fatalf("应从磁盘缓存读出 token/type,实得 %v", m)
|
||||
}
|
||||
if m["client_type"] != "10" {
|
||||
t.Fatalf("Cosy-ClientType 应为 10(参考实测值),实得 %v", m["client_type"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 无任何来源 → 降级:不带这两个头,且不报错", func(t *testing.T) {
|
||||
// 指向不存在的路径,且把 HOME 指到空目录(找不到 runtime-info)
|
||||
t.Setenv("QODER_MACHINE_TOKEN_PATH", filepath.Join(t.TempDir(), "nope.json"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("APPDATA", "")
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("probe 不应报错(必须保守降级): %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("无 json: %#v", got)
|
||||
}
|
||||
if m["token"] != nil && m["token"] != "" {
|
||||
t.Errorf("拿不到身份时不应带 token,实得 %v", m["token"])
|
||||
}
|
||||
if m["type"] != nil && m["type"] != "" {
|
||||
t.Errorf("拿不到身份时不应带 type,实得 %v", m["type"])
|
||||
}
|
||||
// 头仍然要有 clienttype(那是必要但不充分条件,与身份无关)
|
||||
if m["client_type"] != "10" {
|
||||
t.Errorf("降级时仍应带 Cosy-ClientType=10,实得 %v", m["client_type"])
|
||||
}
|
||||
})
|
||||
}
|
||||
181
internal/core/channels_qoder_test.go
Normal file
181
internal/core/channels_qoder_test.go
Normal file
@ -0,0 +1,181 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// qoderHarness:Qoder PKCE 设备码轮的测试台。
|
||||
//
|
||||
// 参考语义(qoder-oauth.ts / qoder.ts 注释点名):
|
||||
//
|
||||
// ① 授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
|
||||
// &nonce&machine_id&client_id —— **本地拼装,不打网络**
|
||||
// ② 轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
|
||||
// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
|
||||
//
|
||||
// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"——
|
||||
// 把聊天加密与登录混为一谈,导致该平台完全不可用。
|
||||
type qoderHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
poll int32 // 已发生的轮询次数
|
||||
// step: 第几次轮询开始返回 token(0 = 一直 404)
|
||||
tokenAt int32
|
||||
gotQuery string
|
||||
}
|
||||
|
||||
func newQoderHarness(t *testing.T) *qoderHarness {
|
||||
t.Helper()
|
||||
h := &qoderHarness{t: t, tokenAt: 2}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/api/v1/deviceToken/poll" {
|
||||
h.gotQuery = r.URL.RawQuery
|
||||
n := atomic.AddInt32(&h.poll, 1)
|
||||
if n < atomic.LoadInt32(&h.tokenAt) {
|
||||
w.WriteHeader(404) // 尚无会话
|
||||
fmt.Fprint(w, `{"code":404,"msg":"no session"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
// 把 poll 指到假上游(授权页是本地拼装,不经网络)
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "qoder",
|
||||
"device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *qoderHarness) begin() (map[string]interface{}, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
func (h *qoderHarness) pollOnce(sid string) map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。
|
||||
func TestQoderDevicePollBehavior(t *testing.T) {
|
||||
h := newQoderHarness(t)
|
||||
|
||||
t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) {
|
||||
j, _ := h.begin()
|
||||
url, _ := j["url"].(string)
|
||||
for _, want := range []string{
|
||||
"qoder.com/device/selectAccounts",
|
||||
"challenge=", "challenge_method=S256", "nonce=", "machine_id=",
|
||||
// client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒)
|
||||
"client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
|
||||
} {
|
||||
if !strings.Contains(url, want) {
|
||||
t.Fatalf("授权页缺 %q:\n%s", want, url)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 404视为继续_不误报失败", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.poll, 0)
|
||||
atomic.StoreInt32(&h.tokenAt, 99) // 一直 404
|
||||
_, sid := h.begin()
|
||||
for i := 0; i < 3; i++ {
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "pending" {
|
||||
t.Fatalf("404 应保持 pending, 实得 %v", st["state"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.poll, 0)
|
||||
atomic.StoreInt32(&h.tokenAt, 99)
|
||||
_, sid := h.begin()
|
||||
h.pollOnce(sid)
|
||||
q := h.gotQuery
|
||||
if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") {
|
||||
t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q)
|
||||
}
|
||||
if !strings.Contains(q, "challenge_method=S256") {
|
||||
t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.poll, 0)
|
||||
atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token
|
||||
_, sid := h.begin()
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("首拍应 pending: %v", st)
|
||||
}
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("拿到 token 后应 completed, 实得 %v", st)
|
||||
}
|
||||
// 凭据落保险库(旧路径键)
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:qoder")
|
||||
if !ok || !strings.Contains(v, "QD-AT-1") {
|
||||
t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.poll, 0)
|
||||
atomic.StoreInt32(&h.tokenAt, 1)
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin",
|
||||
})
|
||||
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("池路径应完成: %v", st)
|
||||
}
|
||||
if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok {
|
||||
t.Fatalf("池路径凭据未落到 channels:qoder:%s", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
165
internal/core/channels_raccoon_test.go
Normal file
165
internal/core/channels_raccoon_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。
|
||||
//
|
||||
// 参考语义(raccoon-oauth.ts 点名):
|
||||
//
|
||||
// ① qrcode_code **本地随机生成**(16 字节 hex),服务端接受任意自造 code
|
||||
// ② 轮询 POST /login_with_qrcode_code {qrcode_code}
|
||||
// → data.status ∈ pending / logging / canceled / success
|
||||
// ③ success 但**缺 token** 视为未完成(否则产出空凭据并卡死)
|
||||
// ④ **任何异常降级为 pending**(网络抖动/畸形响应/未知 status)
|
||||
type raccoonHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
polls int32
|
||||
step int32 // 第几次轮询开始 success
|
||||
gotCode string
|
||||
}
|
||||
|
||||
func newRaccoonHarness(t *testing.T) *raccoonHarness {
|
||||
t.Helper()
|
||||
h := &raccoonHarness{t: t, step: 3}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") {
|
||||
h.polls++
|
||||
if h.polls < atomic.LoadInt32(&h.step) {
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"logging"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"success","access_token":"RC-AT-1","refresh_token":"RC-RT-1"}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "raccoon",
|
||||
"qr": map[string]interface{}{
|
||||
"api_base": h.ts.URL,
|
||||
"auth_prefix": "/api/web/auth/v1",
|
||||
"poll_path": "/login_with_qrcode_code",
|
||||
"login_page": "/login/mp",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *raccoonHarness) begin() (map[string]interface{}, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"raccoon"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestRaccoonQrPollBehavior 逐行锁定参考的扫码登录语义。
|
||||
func TestRaccoonQrPollBehavior(t *testing.T) {
|
||||
h := newRaccoonHarness(t)
|
||||
|
||||
t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
j, _ := h.begin()
|
||||
qr, _ := j["qr_url"].(string)
|
||||
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") {
|
||||
t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr)
|
||||
}
|
||||
// code 应是 32 位 hex(本地随机生成)
|
||||
code := qr[strings.Index(qr, "code=")+5:]
|
||||
if i := strings.IndexAny(code, "&"); i > 0 {
|
||||
code = code[:i]
|
||||
}
|
||||
if len(code) != 32 {
|
||||
t.Fatalf("qrcode_code 应是 32 位 hex, 实得 %q", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 logging保持pending", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.step, 99)
|
||||
_, sid := h.begin()
|
||||
for i := 0; i < 3; i++ {
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 success且带token才完成", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.step, 2)
|
||||
_, sid := h.begin()
|
||||
h.pollOnce(sid)
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("success+token 应 completed, 实得 %v", st)
|
||||
}
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:raccoon")
|
||||
if !ok || !strings.Contains(v, "RC-AT-1") {
|
||||
t.Fatalf("凭据应含 RC-AT-1, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 canceled是终态失败", func(t *testing.T) {
|
||||
hh := newRaccoonHarness(t)
|
||||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"canceled"}}`)
|
||||
})
|
||||
hh.ps.SetSecret("channels", "channels:raccoon", "")
|
||||
_, sid := hh.begin()
|
||||
if st := hh.pollOnce(sid); st["state"] != "failed" {
|
||||
t.Fatalf("canceled 应 failed, 实得 %v", st["state"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
|
||||
hh := newRaccoonHarness(t)
|
||||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"success"}}`)
|
||||
})
|
||||
_, sid := hh.begin()
|
||||
st := hh.pollOnce(sid)
|
||||
if st["state"] != "pending" {
|
||||
t.Fatalf("缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"])
|
||||
}
|
||||
})
|
||||
}
|
||||
193
internal/core/channels_refresh_test.go
Normal file
193
internal/core/channels_refresh_test.go
Normal file
@ -0,0 +1,193 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)。
|
||||
//
|
||||
// ⚠️ 修的是一个**比不做更坏**的缺陷:`account_refresh` 此前只重同步源、一个换
|
||||
// token 的请求都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」
|
||||
// 而实际什么都没续。日志在说谎,排查时会去看网络、看上游,而真因是根本没有
|
||||
// 这段代码。
|
||||
//
|
||||
// 判据必须**看到真请求** —— 否则就还是在验一个恒真的返回值。
|
||||
func TestAccountRefreshSendsRealRequest(t *testing.T) {
|
||||
var hits int32
|
||||
var gotBody, gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
gotPath = r.URL.Path
|
||||
b := make([]byte, 1024)
|
||||
n, _ := r.Body.Read(b)
|
||||
gotBody = string(b[:n])
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 照参考:Cline 响应套 {success,data} 信封,字段是**驼峰** accessToken
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{"accessToken":"NEW-TOKEN-CAMEL","refreshToken":"NEW-REFRESH"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
// 注入一个测试平台(夹具绝不写进生产源码)
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "rfptest", "refresh_url": srv.URL + "/api/v1/auth/refresh",
|
||||
"style": "cline", "credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("注入平台: %v", err)
|
||||
}
|
||||
|
||||
out, err := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||||
"platform": "rfptest", "account_id": "a1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_refresh: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
|
||||
// ① 必须真的发出了请求(这是本判据的核心 —— 原实现 hits=0)
|
||||
if n := atomic.LoadInt32(&hits); n != 1 {
|
||||
t.Fatalf("必须真发一次续期请求,实得 %d 次(原实现是 0 次却报成功)", n)
|
||||
}
|
||||
if !strings.Contains(gotPath, "/api/v1/auth/refresh") {
|
||||
t.Errorf("请求路径不对: %s", gotPath)
|
||||
}
|
||||
// ② 请求体必须是**驼峰** refreshToken/grantType(照参考 cline.ts)
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
|
||||
t.Fatalf("请求体不是 JSON(Cline 要 JSON body): %q", gotBody)
|
||||
}
|
||||
if _, ok := sent["refreshToken"]; !ok {
|
||||
t.Errorf("请求体必须是驼峰 refreshToken(不是 refresh_token),实得 %v", sent)
|
||||
}
|
||||
if sent["grantType"] != "refresh_token" {
|
||||
t.Errorf("grantType 应为 refresh_token,实得 %v", sent["grantType"])
|
||||
}
|
||||
|
||||
// ③ 必须报 refreshed=true,且**新 token 真的落进凭据**
|
||||
if j["refreshed"] != true {
|
||||
t.Errorf("应报 refreshed=true,实得 %v(message=%v)", j["refreshed"], j["message"])
|
||||
}
|
||||
if j["ok"] != true {
|
||||
t.Errorf("应报 ok=true,实得 %v", j["ok"])
|
||||
}
|
||||
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||||
"ref": "channels:rfptest",
|
||||
})
|
||||
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
|
||||
cred, _ := rj["credential"].(string)
|
||||
if !strings.Contains(cred, "NEW-TOKEN-CAMEL") {
|
||||
t.Errorf("新 token 必须写回凭据,实得 %q", cred)
|
||||
}
|
||||
// ④ 轮换的 refresh_token 也要回写(Google 场景;Cline 也返回了新的)
|
||||
if !strings.Contains(cred, "NEW-REFRESH") {
|
||||
t.Errorf("轮换的 refresh_token 必须回写,实得 %q", cred)
|
||||
}
|
||||
}
|
||||
|
||||
// 失败时**必须如实回错误**,不能像原实现那样恒 ok:true。
|
||||
func TestAccountRefreshReportsFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(401)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"refresh token expired"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "rfpfail", "refresh_url": srv.URL + "/refresh", "style": "google",
|
||||
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
out, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||||
"platform": "rfpfail", "account_id": "a1",
|
||||
})
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
// ⚠️ 原实现在这里恒 ok:true/refreshed 隐含成功 —— 这就是日志说谎的机制
|
||||
if j["refreshed"] == true {
|
||||
t.Error("续期失败时绝不能报 refreshed=true")
|
||||
}
|
||||
msg, _ := j["message"].(string)
|
||||
if !strings.Contains(msg, "401") {
|
||||
t.Errorf("失败原因应含 HTTP 状态(可诊断),实得 %q", msg)
|
||||
}
|
||||
|
||||
// 无 refresh_token 时必须明确说"需重新登录",而不是笼统的失败
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "rfpnort", "refresh_url": srv.URL + "/refresh", "style": "google",
|
||||
"credential": `{"access_token":"OLD"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
out2, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||||
"platform": "rfpnort", "account_id": "a1",
|
||||
})
|
||||
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
m2, _ := j2["message"].(string)
|
||||
if !strings.Contains(m2, "重新登录") {
|
||||
t.Errorf("无 refresh_token 时应提示需重新登录,实得 %q", m2)
|
||||
}
|
||||
}
|
||||
|
||||
// expires_in 是**相对秒数**,必须换算成绝对时刻 —— 否则每轮都判定"快过期"再续一遍。
|
||||
func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"NEW","refresh_token":"R2","expires_in":7200}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "rfpexp", "refresh_url": srv.URL + "/token", "style": "google",
|
||||
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
if _, err := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||||
"platform": "rfpexp", "account_id": "a1",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_refresh: %v", err)
|
||||
}
|
||||
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||||
"ref": "channels:rfpexp",
|
||||
})
|
||||
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
|
||||
cred, _ := rj["credential"].(string)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(cred), &m)
|
||||
ea, _ := m["expires_at"].(string)
|
||||
if ea == "" {
|
||||
t.Fatal("expires_in 必须换算成绝对 expires_at 写回(否则每轮都重续)")
|
||||
}
|
||||
// 必须是**绝对**时刻(10 位秒级),不是相对秒数 7200
|
||||
if ea == "7200" {
|
||||
t.Error("expires_at 写成了相对秒数 —— 必须换算成绝对时刻")
|
||||
}
|
||||
if len(ea) < 9 {
|
||||
t.Errorf("expires_at 看着不像绝对时刻: %q", ea)
|
||||
}
|
||||
// 索引的 expiresAt 也必须回写(照 expiry-sync:否则出现"显示已过期但能发消息"分叉)。
|
||||
// ⚠️ 必须通过**账号列表**端点读索引 —— __read_cred_probe 只读保险库,
|
||||
// 用它验索引会永远看到零,把"没验到"误判成"没写"。
|
||||
al, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "rfpexp"})
|
||||
aj, _ := al.(map[string]interface{})["json"].(map[string]interface{})
|
||||
accounts := asArrInterface(aj["accounts"])
|
||||
if len(accounts) == 0 {
|
||||
t.Fatal("账号列表为空")
|
||||
}
|
||||
first, _ := accounts[0].(map[string]interface{})
|
||||
idx, _ := first["expiresAt"].(float64)
|
||||
if idx == 0 {
|
||||
t.Error("索引的 expiresAt 必须一并回写(expiry-sync 的分叉缺陷)")
|
||||
}
|
||||
// 且应当是**绝对**秒级时刻(不是相对 7200)
|
||||
if idx > 0 && idx < 1e9 {
|
||||
t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx)
|
||||
}
|
||||
}
|
||||
215
internal/core/channels_remove_source_perm_test.go
Normal file
215
internal/core/channels_remove_source_perm_test.go
Normal file
@ -0,0 +1,215 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 删除账号必须**真的把源撤掉** —— 且撤不掉时不能静默。
|
||||
//
|
||||
// 真实缺陷:内核的 `remove_source` 需要**单独授予**(Destructive 级,不在
|
||||
// add_source 的授权里)。生产权限集曾只有 add_source ⇒ `dropAccountSource`
|
||||
// 的调用被**拒绝**,而调用方**忽略返回值** ⇒ 静默失败的后果是
|
||||
// **账号已删,它的源仍在对外服务**(用户看到"删了却还在用")。
|
||||
//
|
||||
// 格外隐蔽的一点:`AddSource` 是**同名 upsert**,所以 `syncAccountSource` 里
|
||||
// "先 remove 再 add" 的 remove 失败**看不出来**(add 覆盖掉同名源)——
|
||||
// 只有**纯删除**路径(account_delete / 停用)才暴露。故判据必须走删除路径。
|
||||
func TestAccountDeleteRemovesItsSource(t *testing.T) {
|
||||
t.Run("授予 remove_source 时应撤掉源", func(t *testing.T) {
|
||||
h := newRemoveSourceHarness(t, true)
|
||||
h.addAccount("del-a")
|
||||
if got := h.sourceNames(); !hasName(got, "channels-buddy-del-a") {
|
||||
t.Fatalf("前置条件:账号源应已建立,实得 %v", got)
|
||||
}
|
||||
h.deleteAccount("del-a")
|
||||
if got := h.sourceNames(); hasName(got, "channels-buddy-del-a") {
|
||||
t.Fatalf("账号已删但源仍在 ⇒ 已删账号继续对外服务:%v", got)
|
||||
}
|
||||
t.Log("✓ 删除账号同时撤掉其源")
|
||||
})
|
||||
|
||||
t.Run("未授予 remove_source 时必须**可见地**失败(不能静默)", func(t *testing.T) {
|
||||
h := newRemoveSourceHarness(t, false)
|
||||
h.addAccount("del-b")
|
||||
h.deleteAccount("del-b")
|
||||
// 源确实撤不掉(权限不足)——这是环境的限制,不是我们要判的
|
||||
// 我们要判的是:**这件事必须被记录下来**,而不是消失在一个被丢弃的返回值里。
|
||||
logs := h.eventLog()
|
||||
joined := strings.Join(logs, " | ")
|
||||
if !strings.Contains(joined, "撤源失败") {
|
||||
t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+
|
||||
"「删了账号却还在用」)\n事件日志: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "remove_source") {
|
||||
t.Fatalf("记录里应点明是哪个内核动作被拒(便于给运维可操作提示),实得: %s", joined)
|
||||
}
|
||||
// ★ 必须带上**真正的原因**(not granted),而不是"无返回"这类空话。
|
||||
// 内核桥失败时**第一个返回值是 nil、原因在第二个返回值里** ——
|
||||
// 只接第一个返回值就会把原因丢掉(我第一版正是如此)。
|
||||
if !strings.Contains(joined, "not granted") {
|
||||
t.Fatalf("记录里必须含内核给出的**真实原因**(not granted),"+
|
||||
"否则运维无从下手(只接 call 的第一个返回值就会丢掉原因);实得: %s", joined)
|
||||
}
|
||||
t.Logf("✓ 撤源失败已可见: %s", joined)
|
||||
})
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type removeSourceHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newRemoveSourceHarness(t *testing.T, grantRemove bool) *removeSourceHarness {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 照生产现状:add_source 有、remove_source 视参数而定
|
||||
// 注意:status 是插件自己的导出,与内核权限无关(之前误当权限项)
|
||||
actions := []string{"add_source", "list_sources"}
|
||||
if grantRemove {
|
||||
actions = append(actions, "remove_source")
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", actions, false)
|
||||
return &removeSourceHarness{t: t, ps: ps}
|
||||
}
|
||||
|
||||
func (h *removeSourceHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *removeSourceHarness) deleteAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_delete", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_delete(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *removeSourceHarness) sourceNames() []string {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
var out []string
|
||||
arr, _ := j["sources"].([]interface{})
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
if n, ok := m["name"].(string); ok {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eventLog 取插件事件日志(logEvent 写进 plugin.state.log)。
|
||||
// ⚠ 走探针:`status` 是 HTTP 路由处理器,**不在** plugin.exports 里,
|
||||
// 直接 Call 它会报 `does not export "status"`(我第一版就这么错)。
|
||||
func (h *removeSourceHarness) eventLog() []string {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__event_log_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
var out []string
|
||||
arr, _ := j["log"].([]interface{})
|
||||
for _, e := range arr {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasName(xs []string, want string) bool {
|
||||
for _, x := range xs {
|
||||
if x == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ★ 插件**声明**的内核动作必须覆盖它**实际调用**的全部动作。
|
||||
//
|
||||
// 真实缺陷:`plugin.kernel` 只声明了 5 个动作,而插件在 5 处调用 `remove_source`
|
||||
// —— 声明与使用**漂移**。生产只授了声明过的那些 ⇒ 撤源全部被拒,且因为
|
||||
// `AddSource` 是**同名 upsert**(add 会覆盖,看不出 remove 失败),
|
||||
// 只有**纯删除路径**才暴露:**账号删了,它的源还在对外服务**。
|
||||
//
|
||||
// 判据用**静态扫描**(源码里出现的 call("X"))对比声明清单 —— 这样将来任何人
|
||||
// 加了新的内核调用却忘了声明,都会在这里报红,而不是等线上静默失效。
|
||||
func TestChannelsDeclaresEveryKernelActionItCalls(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 抽出声明清单
|
||||
declStart := strings.Index(s, "plugin.kernel = {")
|
||||
if declStart < 0 {
|
||||
t.Fatal("找不到 plugin.kernel 声明(判据需更新)")
|
||||
}
|
||||
declEnd := strings.Index(s[declStart:], "}")
|
||||
if declEnd < 0 {
|
||||
t.Fatal("plugin.kernel 声明没有收尾大括号")
|
||||
}
|
||||
declBlob := s[declStart : declStart+declEnd]
|
||||
declared := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(declBlob, -1) {
|
||||
declared[m[1]] = true
|
||||
}
|
||||
if len(declared) == 0 {
|
||||
t.Fatal("声明清单解析为空(判据失效)")
|
||||
}
|
||||
|
||||
// ② 抽出实际调用的动作
|
||||
called := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) {
|
||||
called[m[1]] = true
|
||||
}
|
||||
if len(called) == 0 {
|
||||
t.Fatal("没扫描到任何内核调用(判据失效)")
|
||||
}
|
||||
|
||||
// ③ 逐个比对:调用而未声明 = 漏声明(运维照声明授权 ⇒ 该调用必被拒)
|
||||
var missing []string
|
||||
for a := range called {
|
||||
if !declared[a] {
|
||||
missing = append(missing, a)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Fatalf("插件调用了未声明的内核动作 %v\n"+
|
||||
"(运维按 declared 授予 ⇒ 这些调用会被拒;remove_source 漏声明时"+
|
||||
"的表现是「账号删了、源还在对外服务」,且因 AddSource 是 upsert 而静默)",
|
||||
missing)
|
||||
}
|
||||
t.Logf("✓ 声明覆盖全部调用:declared=%d called=%d", len(declared), len(called))
|
||||
}
|
||||
251
internal/core/channels_reorder_effect_test.go
Normal file
251
internal/core/channels_reorder_effect_test.go
Normal file
@ -0,0 +1,251 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ account_reorder 必须**真的改变内核里的源顺序**。
|
||||
//
|
||||
// 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里的
|
||||
// **源注册顺序** → 内核按 candidates 顺序依次尝试。
|
||||
// 而一个源一旦注册,顺序就固定了 —— 改 pool 记录不影响它。故重排后必须按新
|
||||
// 顺序 remove+add 一遍。
|
||||
//
|
||||
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
|
||||
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
|
||||
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
|
||||
h := newReorderHarness(t)
|
||||
h.addAccount("acct-a")
|
||||
h.addAccount("acct-b")
|
||||
|
||||
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
|
||||
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
|
||||
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
|
||||
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
|
||||
// 去掉重注册的变异**照样通过**(判据没咬住)。
|
||||
before := h.sourceOrder()
|
||||
if len(before) < 2 {
|
||||
t.Fatalf("应有 2 个账号源,实得 %v", before)
|
||||
}
|
||||
if before[0] != "channels-buddy-acct-a" {
|
||||
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
|
||||
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
|
||||
}
|
||||
|
||||
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
|
||||
// 目标顺序与初始相反,故只有真的重注册才能达成。
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-b", "acct-a"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := h.sourceOrder()
|
||||
if len(after) < 2 {
|
||||
t.Fatalf("重排后源丢了:%v", after)
|
||||
}
|
||||
|
||||
idxOf := func(list []string, id string) int {
|
||||
for i, n := range list {
|
||||
if n == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a")
|
||||
if b < 0 || a < 0 {
|
||||
t.Fatalf("重排后找不到账号源:%v", after)
|
||||
}
|
||||
if b > a {
|
||||
t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+
|
||||
"实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+
|
||||
"快到期积分仍不会优先消耗)", after)
|
||||
}
|
||||
t.Logf("✓ 源顺序已按重排生效: %v", after)
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type reorderHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newReorderHarness(t *testing.T) *reorderHarness {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ★ 必须授予 add_source:不给的话 syncAccountSource 里内核会明确拒绝
|
||||
// ("kernel action add_source is not granted"),源一个都不会出现 ——
|
||||
// 我第一版漏了它,于是"应有 2 个源,实得 []"。
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
return &reorderHarness{t: t, ps: ps}
|
||||
}
|
||||
|
||||
func (h *reorderHarness) addAccount(id string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("account_add(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sourceOrder 返回内核里 channels-buddy-* 源的顺序(即实际尝试顺序)。
|
||||
//
|
||||
// ⚠ **必须只读**:我第一版在这里顺带调了 __sync_source_probe(它内部是
|
||||
// remove+add)⇒ 改变了被测状态,"测之前先动了被测对象",判据因此不可信
|
||||
// (实测表现:顺序忽而两个忽而一个)。
|
||||
func (h *reorderHarness) sourceOrder() []string {
|
||||
h.t.Helper()
|
||||
ls, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__list_sources_probe: %v", err)
|
||||
}
|
||||
j := jsonOf(h.t, ls)
|
||||
var out []string
|
||||
arr, _ := j["sources"].([]interface{})
|
||||
for _, e := range arr {
|
||||
m, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, _ := m["name"].(string)
|
||||
if len(n) > 15 && n[:15] == "channels-buddy-" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ poolList 的顺序必须**确定**(同秒创建的账号也不例外)。
|
||||
//
|
||||
// 真实缺陷:排序键是 `createdAt`(**秒级**),同秒建的两个账号比较相等,
|
||||
// 而相等时 table.sort 的结果取决于 pairs() 遍历顺序 —— **不确定**。
|
||||
// 实测:按 a,b,c 顺序建三个账号,poolList 回的是 [a,c,b]。
|
||||
//
|
||||
// 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = **内核尝试顺序**,
|
||||
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
|
||||
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
|
||||
func TestPoolListOrderIsDeterministic(t *testing.T) {
|
||||
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
|
||||
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
|
||||
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
|
||||
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
|
||||
//
|
||||
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
|
||||
// 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。
|
||||
h := newReorderHarness(t)
|
||||
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
|
||||
h.addAccount(id)
|
||||
}
|
||||
// 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减)
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效**
|
||||
order := h.sourceOrder()
|
||||
want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a"
|
||||
if got := joinStrings(order, ","); got != want {
|
||||
t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want)
|
||||
}
|
||||
|
||||
// ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同),
|
||||
// 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。
|
||||
h.clearPriorities()
|
||||
if !sameSecond(h.createdAts()) {
|
||||
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.clearPriorities()
|
||||
h.resync("buddy")
|
||||
order2 := h.sourceOrder()
|
||||
// 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序
|
||||
want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c"
|
||||
if got := joinStrings(order2, ","); got != want2 {
|
||||
t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
|
||||
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
|
||||
got, want2)
|
||||
}
|
||||
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
|
||||
}
|
||||
|
||||
func joinStrings(xs []string, sep string) string {
|
||||
out := ""
|
||||
for i, x := range xs {
|
||||
if i > 0 {
|
||||
out += sep
|
||||
}
|
||||
out += x
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// createdAts 取当前 pool 里 buddy 账号的 createdAt。
|
||||
func (h *reorderHarness) createdAts() []int64 {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
var out []int64
|
||||
for _, a := range accountsOf(h.t, res) {
|
||||
if v, ok := a["createdAt"].(float64); ok {
|
||||
out = append(out, int64(v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameSecond 判断是否所有时间戳都落在同一秒(即排序键相等)。
|
||||
func sameSecond(ts []int64) bool {
|
||||
if len(ts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, v := range ts {
|
||||
if v != ts[0] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// clearPriorities 把所有账号的 priority 拉平(构造"排序键完全相同"的场景)。
|
||||
func (h *reorderHarness) clearPriorities() {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "__clear_priorities_probe", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
}); err != nil {
|
||||
h.t.Fatalf("__clear_priorities_probe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resync 触发一次批量重注册(走 poolList 顺序 → 源注册顺序)。
|
||||
func (h *reorderHarness) resync(platform string) {
|
||||
h.t.Helper()
|
||||
if _, err := h.ps.Call("channels", "__resync_sources_probe", map[string]interface{}{
|
||||
"platform": platform,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("__resync_sources_probe: %v", err)
|
||||
}
|
||||
}
|
||||
125
internal/core/channels_store_atomic_test.go
Normal file
125
internal/core/channels_store_atomic_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 账号池落盘的**原子性与一致性**(照参考 account-pool.ts 的真实缺陷)。
|
||||
//
|
||||
// 参考的缺陷:`removeAccount` 只保证「读→改」之间无 await,而内部的
|
||||
// `await store.save(...)` 是**异步**的 ⇒ 两个写者各自的 save 可以**乱序完成**,
|
||||
// 后完成者带着它更早的快照覆盖磁盘。实测形态:
|
||||
// - 并发删 a / 删 b(先发起的后完成)⇒ **已删除的账号在磁盘上复活**;
|
||||
// - 删 victim 与加 added 交错 ⇒ **新增账号被静默丢失**。
|
||||
//
|
||||
// 我们的实现是**同步** tmp+rename(Lua 无异步),且每次写都序列化**当前**
|
||||
// `plugin.state`(不是调用方早先取的快照)⇒ 该竞态结构上不可能发生。
|
||||
// 但"不可能"必须被验证,否则只是一句自信。
|
||||
func TestAccountPoolStoreIsAtomicAndConsistent(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
// 找出插件的存储目录(内核给的 ground truth,不是猜的)
|
||||
dirOut, err := ps.Call("channels", "__store_dir_probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("store_dir_probe: %v", err)
|
||||
}
|
||||
dj, _ := dirOut.(map[string]interface{})["json"].(map[string]interface{})
|
||||
dir, _ := dj["store_dir"].(string)
|
||||
if dir == "" {
|
||||
t.Fatalf("拿不到插件存储目录,实得 %#v", dirOut)
|
||||
}
|
||||
storePath := filepath.Join(dir, ".channels-store.json")
|
||||
t.Logf("插件存储目录: %s", dir)
|
||||
// ⚠️ 必须在**测试目录**里,绝不能是生产路径
|
||||
if _, err := os.Stat("/etc/llmsproxy/plugins/.channels-store.json"); err == nil {
|
||||
if filepath.Clean(dir) == "/etc/llmsproxy/plugins" {
|
||||
t.Fatal("测试正在使用生产存储目录 —— 隔离失效(这会造成真实数据事故)")
|
||||
}
|
||||
}
|
||||
|
||||
// 连续做一组"读-改-写"(模拟并发操作最终落在同一份文档上)
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "atomic1", "style": "google", "credential": `{"access_token":"a"}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"a", "b", "c"} {
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "atomic1", "account_id": id}); err != nil {
|
||||
t.Fatalf("add %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// 删 a、删 b(参考的缺陷场景)
|
||||
for _, id := range []string{"a", "b"} {
|
||||
if _, err := ps.Call("channels", "account_delete", map[string]interface{}{
|
||||
"platform": "atomic1", "account_id": id}); err != nil {
|
||||
t.Fatalf("delete %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// 再加一个(参考的第二个缺陷场景:与删除交错的写)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "atomic1", "account_id": "d"}); err != nil {
|
||||
t.Fatalf("add d: %v", err)
|
||||
}
|
||||
|
||||
// 内存视图
|
||||
mem, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "atomic1"})
|
||||
mj, _ := mem.(map[string]interface{})["json"].(map[string]interface{})
|
||||
memIDs := map[string]bool{}
|
||||
for _, a := range asArrInterface(mj["accounts"]) {
|
||||
if am, ok := a.(map[string]interface{}); ok {
|
||||
if id, ok := am["id"].(string); ok {
|
||||
memIDs[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 磁盘视图:必须是**合法 JSON**(tmp+rename 的原子性保证:绝无半截文件)
|
||||
b, err := os.ReadFile(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("读存储: %v", err)
|
||||
}
|
||||
var disk map[string]interface{}
|
||||
if err := json.Unmarshal(b, &disk); err != nil {
|
||||
t.Fatalf("磁盘文档不是合法 JSON(原子替换失效,出现半截文件): %v", err)
|
||||
}
|
||||
// 文档形状:{version, state:{pool, accounts, ...}} —— pool 在 state 之下。
|
||||
st, _ := disk["state"].(map[string]interface{})
|
||||
if st == nil {
|
||||
t.Fatalf("磁盘文档缺 state 段,实得 %v", disk)
|
||||
}
|
||||
pool, _ := st["pool"].(map[string]interface{})
|
||||
diskIDs := map[string]bool{}
|
||||
if pl, ok := pool["atomic1"].(map[string]interface{}); ok {
|
||||
for id := range pl {
|
||||
diskIDs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ① 已删除的账号**不得**在磁盘上复活(参考的第一个缺陷形态)
|
||||
for _, gone := range []string{"a", "b"} {
|
||||
if diskIDs[gone] {
|
||||
t.Errorf("已删除的账号 %q 在磁盘上复活了(参考的并发写竞态形态)", gone)
|
||||
}
|
||||
if memIDs[gone] {
|
||||
t.Errorf("已删除的账号 %q 仍在内存视图里", gone)
|
||||
}
|
||||
}
|
||||
// ② 新增的账号**不得**被静默丢失(参考的第二个缺陷形态)
|
||||
if !diskIDs["d"] {
|
||||
t.Error("新增账号 d 在磁盘上缺失(被静默丢失 —— 参考的竞态形态)")
|
||||
}
|
||||
// ③ 内存与磁盘必须一致(分叉本身就是缺陷)
|
||||
for id := range memIDs {
|
||||
if !diskIDs[id] {
|
||||
t.Errorf("账号 %q 在内存里有、磁盘上没有(内存/磁盘分叉)", id)
|
||||
}
|
||||
}
|
||||
for id := range diskIDs {
|
||||
if !memIDs[id] {
|
||||
t.Errorf("账号 %q 在磁盘上有、内存里没有(内存/磁盘分叉)", id)
|
||||
}
|
||||
}
|
||||
_ = c
|
||||
}
|
||||
222
internal/core/channels_store_test.go
Normal file
222
internal/core/channels_store_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
|
||||
//
|
||||
// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4
|
||||
// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误**
|
||||
// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现
|
||||
// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化
|
||||
// 为纯内存,正解是插件自持文档。
|
||||
//
|
||||
// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。
|
||||
func TestChannelsStoreIsPluginOwned(t *testing.T) {
|
||||
// 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
|
||||
// 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
|
||||
load := func() *lua.Plugins {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "list_sources", "remove_source", "status"}, false)
|
||||
return ps
|
||||
}
|
||||
|
||||
ps := load()
|
||||
// 加账号 → 状态变更 → 应立即落盘
|
||||
add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
|
||||
// ① 插件自有存储文件必须存在
|
||||
storePath := filepath.Join(dir, ".channels-store.json")
|
||||
b, err := os.ReadFile(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("插件自有存储未写出(%s): %v\n"+
|
||||
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatalf("存储文件不是合法 JSON: %v", err)
|
||||
}
|
||||
pool, _ := saved.State["pool"].(map[string]interface{})
|
||||
buddy, _ := pool["buddy"].(map[string]interface{})
|
||||
if _, ok := buddy[aid]; !ok {
|
||||
t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool)
|
||||
}
|
||||
|
||||
// ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600)
|
||||
if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 {
|
||||
t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)")
|
||||
}
|
||||
raw := string(b)
|
||||
for _, bad := range []string{"access_token", "refresh_token", "api_key"} {
|
||||
if strings.Contains(raw, bad) {
|
||||
t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 新实例(模拟重启)必须读回账号
|
||||
ps2 := load()
|
||||
lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_list: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||||
if raw.(map[string]interface{})["id"] == aid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("重启后账号 %s 未恢复 —— 持久化未生效", aid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件
|
||||
// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。
|
||||
//
|
||||
// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 ——
|
||||
// 写失败还删它,等于用一次迁移把数据弄丢。
|
||||
func TestChannelsMigratesLegacyKernelState(t *testing.T) {
|
||||
// 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离)
|
||||
dir := ""
|
||||
|
||||
// 造一个内核遗留文件(v2.0.4 的形状)
|
||||
legacy := map[string]interface{}{
|
||||
"version": 1,
|
||||
"state": map[string]interface{}{
|
||||
"accounts": map[string]interface{}{},
|
||||
"log": []interface{}{},
|
||||
"pool": map[string]interface{}{
|
||||
"buddy": map[string]interface{}{
|
||||
"legacy-acct": map[string]interface{}{
|
||||
"id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号",
|
||||
"enabled": true, "credentialRef": "channels:buddy:legacy-acct",
|
||||
"createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(legacy)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
dir = c.Config().PluginDir
|
||||
legacyPath := filepath.Join(dir, ".channels.state.json")
|
||||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 遗留账号必须被迁移进内存(可见)
|
||||
lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||||
if raw.(map[string]interface{})["id"] == "legacy-acct" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("遗留账号未迁移 —— 升级即丢账号")
|
||||
}
|
||||
|
||||
// ② 自有存储必须已写出
|
||||
if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil {
|
||||
t.Fatalf("迁移后自有存储应存在: %v", err)
|
||||
}
|
||||
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatal("遗留内核文件应被删除,否则下次启动会被它覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约:
|
||||
// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把
|
||||
// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。
|
||||
//
|
||||
// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在
|
||||
// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。
|
||||
func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
|
||||
// 强制写失败的手段:把 persistState 要写的临时文件路径
|
||||
// (<dir>/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
|
||||
// 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
|
||||
sub := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
|
||||
|
||||
legacy := map[string]interface{}{
|
||||
"version": 1,
|
||||
"state": map[string]interface{}{
|
||||
"pool": map[string]interface{}{
|
||||
"buddy": map[string]interface{}{
|
||||
"keepme": map[string]interface{}{
|
||||
"id": "keepme", "provider": "buddy",
|
||||
"credentialRef": "channels:buddy:keepme", "createdAt": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(legacy)
|
||||
legacyPath := filepath.Join(sub, ".channels.state.json")
|
||||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.LoadSource("channels", src)
|
||||
|
||||
// 写失败的态下,遗留文件必须原样保留
|
||||
if _, err := os.Stat(legacyPath); err != nil {
|
||||
t.Fatalf("写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
|
||||
}
|
||||
// 且自有存储不应存在(写失败了)
|
||||
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {
|
||||
t.Fatal("tmp 路径被占住时不应写出自有存储")
|
||||
}
|
||||
}
|
||||
264
internal/core/channels_stress_test.go
Normal file
264
internal/core/channels_stress_test.go
Normal file
@ -0,0 +1,264 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// mustBox 构造一个真 SecretBox(与运行期同一实现,密钥落 runtime dir)。
|
||||
func mustBox(t *testing.T, runtimeFile string) *config.SecretBox {
|
||||
t.Helper()
|
||||
box, err := config.NewSecretBox(runtimeFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// channelsCore 起一个带 channels 插件、可指定源码与 box 的核心(压力测试用)。
|
||||
func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatalf("加载 channels: %v", err)
|
||||
}
|
||||
ps.SetSecretBox(mustBox(t, filepath.Join(t.TempDir(), "runtime.json")))
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
return c, ps
|
||||
}
|
||||
|
||||
// cookieishPlatformFixture 是压力测试用的占位平台(对应此前内联在生产源码里的
|
||||
// "cookieish")。夹具经运行时注入,生产目录只含真实厂商。
|
||||
const cookieishPlatformFixture = `[{
|
||||
"id": "cookieish",
|
||||
"name": "Cookie 平台(测试夹具)",
|
||||
"flow": "paste",
|
||||
"base_url": "https://api.cookieish.example/v1",
|
||||
"adapter": "openai",
|
||||
"models": ["c-model-1"],
|
||||
"checkin": {"url": "https://api.cookieish.example/v1/api/credit/claim", "method": "POST", "body": "{}"}
|
||||
}]`
|
||||
|
||||
// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。
|
||||
//
|
||||
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
|
||||
// 凭据互不可见、最终都 completed),真吞吐压力在 e2e 层(真二进制+HTTP 并发)。
|
||||
func TestChannelsConcurrentLogins(t *testing.T) {
|
||||
var hits int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/oauth2/device/code":
|
||||
n := atomic.AddInt32(&hits, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"device_code":"DEV-%d","user_code":"UC-%d","verification_uri":"https://example/activate","interval":1,"expires_in":600}`, n, n)
|
||||
case "/oauth2/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"access_token":"TOK","refresh_token":"RF","nickname":"u"}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := channelsCore(t, mustChannelsSrc(t))
|
||||
// 把 minimax 端点指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "minimax",
|
||||
"device": map[string]interface{}{
|
||||
"code_url": ts.URL + "/oauth2/device/code",
|
||||
"token_url": ts.URL + "/oauth2/token",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const rounds = 12
|
||||
var wg sync.WaitGroup
|
||||
var failures int32
|
||||
for i := 0; i < rounds; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"minimax"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("start_login #%d: %v", n, err)
|
||||
atomic.AddInt32(&failures, 1)
|
||||
return
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
sess, _ := j["session"].(map[string]interface{})
|
||||
if sess == nil {
|
||||
t.Errorf("start_login #%d 无会话: %v", n, j)
|
||||
atomic.AddInt32(&failures, 1)
|
||||
return
|
||||
}
|
||||
sid := sess["id"].(string)
|
||||
for k := 0; k < 10; k++ {
|
||||
out2, _ := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid},
|
||||
})
|
||||
m2 := out2.JSON.(map[string]interface{})
|
||||
if m2["state"] == "completed" {
|
||||
return
|
||||
}
|
||||
if m2["state"] == "failed" {
|
||||
t.Errorf("会话 #%d failed: %v", n, m2)
|
||||
atomic.AddInt32(&failures, 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("会话 #%d 10 次轮询未终态", n)
|
||||
atomic.AddInt32(&failures, 1)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if failures != 0 {
|
||||
t.Fatalf("并发登录失败 %d 轮", failures)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsClaimStress 压力:同一平台并发领签到(幂等 + 无登录态丢失)。
|
||||
func TestChannelsClaimStress(t *testing.T) {
|
||||
var claims int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/checkin" {
|
||||
atomic.AddInt32(&claims, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"ok":true,"credits":10}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, `{}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := channelsCore(t, mustChannelsSrc(t))
|
||||
injectTestPlatforms(t, ps, cookieishPlatformFixture)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "cookieish",
|
||||
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.SetSecret("channels", "channels:cookieish", "PASTE-TOKEN"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const n = 30
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(k int) {
|
||||
defer wg.Done()
|
||||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"cookieish"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("claim #%d: %v", k, err)
|
||||
return
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
if j["ok"] != true {
|
||||
t.Errorf("claim #%d 未成功: %v", k, j)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if got := atomic.LoadInt32(&claims); got != n {
|
||||
t.Errorf("上游应收到 %d 次领取, 实得 %d", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyVaultPlaintextLoad 旧通路:老版本(无加密)写的明文库在新版本
|
||||
// (带 box)下必须能读 —— 凭据升级不丢。落盘则会在下次写时整体重封为密文。
|
||||
func TestLegacyVaultPlaintextLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := lua.NewPlugins(nil, dir)
|
||||
if err := ps.SetSecret("legacy", "channels:old", "PLAIN-OLD-TOKEN"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.SetSecretBox(mustBox(t, filepath.Join(dir, "runtime.json")))
|
||||
v, ok, err := ps.GetSecret("legacy", "channels:old")
|
||||
if err != nil || !ok || v != "PLAIN-OLD-TOKEN" {
|
||||
t.Fatalf("旧明文库读回失败: v=%q ok=%v err=%v", v, ok, err)
|
||||
}
|
||||
// 任意一次写触发整体重封
|
||||
if err := ps.SetSecret("legacy", "k2", "NEW"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, ".secrets", "legacy.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "PLAIN-OLD-TOKEN") {
|
||||
t.Fatalf("重封后旧明文仍在盘上:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "enc:v1:") {
|
||||
t.Fatalf("重封后应含密文:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacySourceSealedConfig 旧通路:config.yaml 里明文 api_key 的旧配置,
|
||||
// 新版本加载后运行期拿到解密值、落盘被 seal(SealIfNeeded 语义)。
|
||||
func TestLegacySourceSealedConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
yaml := "listen: 127.0.0.1:0\n" +
|
||||
"gateway_keys:\n - sk-legacy-0001\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"sources:\n" +
|
||||
" - name: legacy-src\n" +
|
||||
" base_url: https://legacy.example.com/v1\n" +
|
||||
" adapter: openai\n" +
|
||||
" api_key: PLAIN-LEGACY-KEY\n" +
|
||||
" max_concurrent: 2\n" +
|
||||
" models:\n" +
|
||||
" - id: m1\n" +
|
||||
" kind: chat\n" +
|
||||
" priority: 10\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
got := c.Sources()
|
||||
if len(got) != 1 || got[0].APIKey != "PLAIN-LEGACY-KEY" {
|
||||
t.Fatalf("运行期 api_key 应解密: %#v", got)
|
||||
}
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "PLAIN-LEGACY-KEY") {
|
||||
t.Fatalf("旧明文 api_key 升级后仍明文落盘:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "enc:v1:") {
|
||||
t.Fatalf("落盘应含 enc:v1 密文:\n%s", raw)
|
||||
}
|
||||
mk, err := os.ReadFile(filepath.Join(dir, "master.key"))
|
||||
if err != nil || len(mk) != 64 {
|
||||
t.Fatalf("master.key 应 64 hex, len=%d err=%v", len(mk), err)
|
||||
}
|
||||
}
|
||||
230
internal/core/channels_trae_catalog_test.go
Normal file
230
internal/core/channels_trae_catalog_test.go
Normal file
@ -0,0 +1,230 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上
|
||||
// (参考 parseTraeBatchModelList 的注释记录了它们)。
|
||||
//
|
||||
// ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7):
|
||||
//
|
||||
// 22 个 function 里有一半在本插件推理端点下不可调用(实测 chat→4023
|
||||
// model unknown、builder→4001、inline_chat→3003)。旧实现只把白名单用于
|
||||
// "同模型择优",于是"目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,
|
||||
// 且错误指向**模型**,极易被误判成"这个模型坏了"。
|
||||
//
|
||||
// ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7):
|
||||
//
|
||||
// 旧实现无条件"后覆盖前",假设(后面的配置更全)与真实数据**相反** ——
|
||||
// 上游把空档位的 solo_work_lite 排在最后,实测 13 个模型因此丢掉
|
||||
// reasoning_effort_config,全部显示"未提供推理等级"。
|
||||
func TestTraeBatchModelCatalogAdmissionAndReasoningProvenance(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var gotBody string
|
||||
h := newTraeCatalogHarness(t, func(body string) {
|
||||
mu.Lock()
|
||||
gotBody = body
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
ids := catalogIDs(t, h, "trae")
|
||||
joined := strings.Join(ids, ",")
|
||||
|
||||
// ① 白名单准入:不可调用通道里的模型必须**被丢弃**
|
||||
if strings.Contains(joined, "only-in-bad-channel") {
|
||||
t.Fatalf("不可调用通道(chat/builder/inline_chat)里的模型必须整组丢弃,"+
|
||||
"否则用户选中它必然失败且错误指向模型:%v", ids)
|
||||
}
|
||||
// 但通用通道里的模型要在
|
||||
if !strings.Contains(joined, "glm-5.2") {
|
||||
t.Fatalf("可调用通道里的模型不该被丢:%v", ids)
|
||||
}
|
||||
|
||||
// ② 空档位不得覆盖有档位:同一模型在 solo_agent(有档位)与
|
||||
// solo_work_lite(空档位,**排在后面**)都出现 ⇒ 必须保留有档位那条。
|
||||
// 判据看**报出的档位**,不是"哪条被选中"这类内部信号。
|
||||
if !h.modelHasReasoningOptions("glm-5.2") {
|
||||
t.Fatalf("glm-5.2 的思考档位被空档位条目覆盖了 —— 上游把空档位的"+
|
||||
"solo_work_lite 排在最后,无条件后覆盖前会让模型显示"+
|
||||
"「未提供推理等级」(issue IKI7WT/IKILR7):%v", ids)
|
||||
}
|
||||
|
||||
// ③ 请求体必须传**全部 22 个 function**(与真实 IDE 一致)——
|
||||
// 只传聊天通道会让非聊天通道条目在响应里位置错乱甚至被跳过。
|
||||
mu.Lock()
|
||||
b := gotBody
|
||||
mu.Unlock()
|
||||
if b == "" {
|
||||
t.Fatal("未捕获到目录请求体")
|
||||
}
|
||||
for _, want := range []string{"ui_builder_v2", "inline_chat", "system_diagnosis", "show_custom_model"} {
|
||||
if !strings.Contains(b, want) {
|
||||
t.Fatalf("目录请求体必须与真实 IDE 一致传全部 function(缺 %q):%s", want, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 四条硬性过滤:usage / config_switch / is_invisible_to_user / 必须有 function。
|
||||
func TestTraeBatchModelCatalogHardFilters(t *testing.T) {
|
||||
h := newTraeCatalogHarness(t, nil)
|
||||
ids := catalogIDs(t, h, "trae")
|
||||
joined := strings.Join(ids, ",")
|
||||
for _, bad := range []string{"summary-model", "disabled-model", "hidden-model", "custom-flag-model"} {
|
||||
if strings.Contains(joined, bad) {
|
||||
t.Errorf("%s 应被硬性过滤掉(usage/switch/invisible):%v", bad, ids)
|
||||
}
|
||||
}
|
||||
// ⚠ 判据是**标志的值**,不是模型名:名字带 custom_model_ 前缀但标志为 false
|
||||
// 的条目必须保留(参考实测:曾据此把 qwen3.8-flash 误记为应剔除,它其实可用)。
|
||||
if !strings.Contains(joined, "custom_model_prefix_but_ok") {
|
||||
t.Errorf("is_custom_model=false 的条目必须保留(判据是标志值而非模型名):%v", ids)
|
||||
}
|
||||
// ⚠ 我第一版把 no-function-model 放进 solo_agent 的 config_info_list 里,
|
||||
// 却断言它应被丢弃 —— 那是**夹具写错**:它在合法组内,保留才是对的。
|
||||
// 「未声明 function」指的是**整个 function_configs 组**没有 function 字段。
|
||||
if !strings.Contains(joined, "no-function-model") {
|
||||
t.Errorf("合法组内的条目不该被丢:%v", ids)
|
||||
}
|
||||
t.Logf("✓ 过滤后目录: %v", ids)
|
||||
}
|
||||
|
||||
// ── harness:假上游返回一份 batch_get_detail_param 形态的目录 ──
|
||||
|
||||
type traeCatalogHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarness {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if onBody != nil {
|
||||
onBody(string(b))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 三个通道:
|
||||
// solo_agent(**可调用**,白名单内)—— glm-5.2 **有**档位
|
||||
// solo_work_lite(白名单内,**排在后面**)—— glm-5.2 **空档位**
|
||||
// inline_chat(**白名单外**)—— 只列出 only-in-bad-channel
|
||||
fmt.Fprint(w, `{"function_configs":[`+
|
||||
// ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目
|
||||
`{"function":"solo_agent","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
|
||||
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
|
||||
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
|
||||
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
|
||||
`{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
|
||||
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+
|
||||
// is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用")
|
||||
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":true}},`+
|
||||
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
|
||||
// (参考点名:判据是标志的值,不是模型名)
|
||||
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
|
||||
`"display_config":{"is_custom_model":false}}`+
|
||||
`]},`+
|
||||
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置
|
||||
`{"function":"solo_work_lite","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+
|
||||
`]},`+
|
||||
// ③ 白名单外通道:整组必须被丢弃
|
||||
`{"function":"inline_chat","config_info_list":[`+
|
||||
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
|
||||
`]}`+
|
||||
`]}`)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &traeCatalogHarness{t: t, ps: ps}
|
||||
// 把目录端点指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"models_remote": map[string]interface{}{"endpoint0": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 塞一个 TRAE 账号(目录请求需要凭据)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"account_id": "cat-trae",
|
||||
"credential": `{"access_token":"AT","uid":"u1","refresh_token":"RT"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func catalogIDs(t *testing.T, h *traeCatalogHarness, platform string) []string {
|
||||
t.Helper()
|
||||
if _, err := h.ps.Call("channels", "model_refresh",
|
||||
map[string]interface{}{"platform": platform}); err != nil {
|
||||
t.Fatalf("model_refresh: %v", err)
|
||||
}
|
||||
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": platform})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
var out []string
|
||||
arr, _ := j["models"].([]interface{})
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
if id, ok := m["id"].(string); ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// modelHasReasoningOptions 查某模型是否被报出思考档位(走 model_list 的字段)。
|
||||
func (h *traeCatalogHarness) modelHasReasoningOptions(modelID string) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(h.t, res)
|
||||
arr, _ := j["models"].([]interface{})
|
||||
for _, e := range arr {
|
||||
m, ok := e.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["id"] != modelID {
|
||||
continue
|
||||
}
|
||||
// 档位的真实出口:model_list 把远端目录解出的档位放在
|
||||
// `remote.efforts`(并镜像到顶层 efforts),另有 reasoning=true。
|
||||
for _, k := range []string{"efforts", "reasoning_options", "reasoningOptions"} {
|
||||
if v, ok := m[k].([]interface{}); ok && len(v) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if rm, ok := m["remote"].(map[string]interface{}); ok {
|
||||
if v, ok := rm["efforts"].([]interface{}); ok && len(v) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
111
internal/core/channels_trae_checkin_test.go
Normal file
111
internal/core/channels_trae_checkin_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// Trae 签到是最容易被"看起来对"骗过的地方:请求能发出去、上游可能也返回 200,
|
||||
// 但头不对就只是一个失败/风控响应。参考实现明确记录:
|
||||
//
|
||||
// ① 授权方案是 Cloud-IDE-JWT,**不是** Bearer(用错前缀是 401 级失败);
|
||||
// ② 简化头(只有 Authorization + 几个 Ug 头)**实测签到失败**,
|
||||
// 真正成功的是约 20 个客户端头;
|
||||
// ③ X-Device-Id 不是凭据里的 hex32,而是**基于 user_id 确定性派生的 15 位数字**
|
||||
// (每账号天然不同设备,所以不再需要 9074 时换设备号重试)。
|
||||
//
|
||||
// 本测试按参考逐条锁定这些事实,并把派生的 device_id 钉死到与参考一致的值。
|
||||
func TestTraeCheckinHeaders(t *testing.T) {
|
||||
var got map[string]string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = map[string]string{}
|
||||
for k := range r.Header {
|
||||
got[k] = r.Header.Get(k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把签到端点指到假上游(不覆盖 checkin.auth,验证声明真的在起作用)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"checkin": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// uid 必须是真值:设备身份由它派生。用参考实现算出的期望值(见 tools/trae_derive_expected.mjs)
|
||||
const uid = "12345"
|
||||
// credential 传**字符串**(凭据是一段 JSON 文本),不是对象 —— complete_login
|
||||
// 会原样存进保险库,取用时 credentialOf 再解出 access_token。
|
||||
cred, _ := json.Marshal(map[string]interface{}{
|
||||
"access_token": "ACCESS-1", "uid": uid, "device_id": "deadbeef",
|
||||
})
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"platform": "trae", "credential": string(cred), "account": "t",
|
||||
})
|
||||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: string(body),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim_daily: %v", err)
|
||||
}
|
||||
if j, _ := out.JSON.(map[string]interface{}); j != nil && j["error"] != nil {
|
||||
t.Fatalf("claim 失败: %v(完整响应 %v)", j["error"], j)
|
||||
}
|
||||
|
||||
t.Run("行1 授权方案是 Cloud-IDE-JWT", func(t *testing.T) {
|
||||
if v := got["Authorization"]; v != "Cloud-IDE-JWT ACCESS-1" {
|
||||
t.Fatalf("Authorization 必须是 Cloud-IDE-JWT <token>(Bearer 会 401),实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行2 设备身份是按 user_id 派生的 15 位数字", func(t *testing.T) {
|
||||
// 期望值来自参考实现(Node + node:crypto)的派生,不是本实现自己算的
|
||||
if v := got["X-Device-Id"]; v != "707304999019651" {
|
||||
t.Fatalf("X-Device-Id 应为按 uid 派生的 15 位数字 707304999019651,实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行3 客户端身份头齐全", func(t *testing.T) {
|
||||
for k, want := range map[string]string{
|
||||
"X-Market-Client-Id": "VSCode 1.107.1",
|
||||
"X-User-Region": "CN",
|
||||
"Package-Type": "stable_cn",
|
||||
"X-Lgw-Req-Sdk-Type": "3",
|
||||
"X-Lscbd-Platform": "windows",
|
||||
"App-Version": "0.1.52",
|
||||
} {
|
||||
if v := got[k]; v != want {
|
||||
t.Errorf("头 %s = %q,期望 %q", k, v, want)
|
||||
}
|
||||
}
|
||||
if got["X-Market-User-Id"] == "" || len(got["X-Market-User-Id"]) != 36 {
|
||||
t.Errorf("X-Market-User-Id 应是 UUID v4 形状,实得 %q", got["X-Market-User-Id"])
|
||||
}
|
||||
if len(got["Vscode-Sessionid"]) != 64 {
|
||||
t.Errorf("Vscode-Sessionid 应是 64 位 hex,实得 %q", got["Vscode-Sessionid"])
|
||||
}
|
||||
if got["X-Tt-Trace-Id"] == "" || got["X-Request-Id"] == "" {
|
||||
t.Error("每次请求都应有独立的 X-Tt-Trace-Id / X-Request-Id")
|
||||
}
|
||||
})
|
||||
}
|
||||
63
internal/core/channels_trae_derive_test.go
Normal file
63
internal/core/channels_trae_derive_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 手工移植的确定性派生必须与参考**逐字节一致**,否则签名的设备身份是错的
|
||||
// 且不会报错(上游只会返回风控/失败)。所以拿参考实现(Node + node:crypto)
|
||||
// 生成期望值,再让插件的 Lua 实现算同一组输入做差分。
|
||||
//
|
||||
// 期望值由 tools/trae_derive_expected.json 提供(用参考 seededStream 生成)。
|
||||
func TestTraeDeriveMatchesReference(t *testing.T) {
|
||||
raw, err := os.ReadFile("trae_derive_expected.json")
|
||||
if err != nil {
|
||||
t.Skipf("缺差分数据(跳过): %v", err)
|
||||
}
|
||||
var cases []struct {
|
||||
U string `json:"u"`
|
||||
Devid string `json:"devid"`
|
||||
Market string `json:"market"`
|
||||
Sess string `json:"sess"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got, err := ps.Call("channels", "__trae_derive_probe", map[string]interface{}{"uid": tc.U})
|
||||
if err != nil {
|
||||
t.Fatalf("probe %q: %v", tc.U, err)
|
||||
}
|
||||
wrapped, _ := got.(map[string]interface{})
|
||||
m, _ := wrapped["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("probe %q 无 json: %#v", tc.U, got)
|
||||
}
|
||||
if fmt.Sprint(m["devid"]) != tc.Devid {
|
||||
t.Errorf("devid(%q) = %v,参考 %s", tc.U, m["devid"], tc.Devid)
|
||||
}
|
||||
if fmt.Sprint(m["market"]) != tc.Market {
|
||||
t.Errorf("market(%q) = %v,参考 %s", tc.U, m["market"], tc.Market)
|
||||
}
|
||||
if fmt.Sprint(m["sess"]) != tc.Sess {
|
||||
t.Errorf("sess(%q) = %v,参考 %s", tc.U, m["sess"], tc.Sess)
|
||||
}
|
||||
}
|
||||
_ = lua.HandlerRequest{}
|
||||
}
|
||||
200
internal/core/channels_trae_pkce_test.go
Normal file
200
internal/core/channels_trae_pkce_test.go
Normal file
@ -0,0 +1,200 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
|
||||
// 而不是去调 ExchangeToken 换一个不存在的码。
|
||||
//
|
||||
// 真实缺陷(用户报「登录后显示失败」):
|
||||
// poll_login 的 trae 分支曾自己发明了一段「授权码交换」——把 {code} 塞进
|
||||
// exchange_body 再打 ExchangeToken。但:
|
||||
//
|
||||
// · ExchangeToken 的签名是 RefreshToken,不是 code;
|
||||
// · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
|
||||
// · client_id 当时是空串。
|
||||
//
|
||||
// 于是稳定得到 400,日志回一句误导性的「Trae 授权码交换失败: 400」,
|
||||
// 把真因「上游走了 PKCE 流程」掩盖成「交换出错了」——排查方向完全错。
|
||||
//
|
||||
// 参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法是:带 code 的回调
|
||||
// 是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
|
||||
func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。
|
||||
// oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就
|
||||
// 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。
|
||||
//
|
||||
// poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state},
|
||||
// 故判据直接摆好那个文件再调 poll_login。
|
||||
status, body := h.beginAndPollWithCallback(map[string]string{
|
||||
"code": "PKCE_AUTH_CODE_XYZ",
|
||||
})
|
||||
|
||||
// poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。
|
||||
// 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把
|
||||
// 前者写成了后者,于是把正确的实现判成错。
|
||||
if !strings.Contains(body, "state:failed") {
|
||||
t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s",
|
||||
status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
|
||||
t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+
|
||||
"实际打了 %d 次", n)
|
||||
}
|
||||
// 错误信息必须指向真因(PKCE),而不是含糊的交换失败
|
||||
blob := body
|
||||
if !strings.Contains(blob, "PKCE") {
|
||||
t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob)
|
||||
}
|
||||
if strings.Contains(blob, "授权码交换失败") {
|
||||
t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob)
|
||||
}
|
||||
t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob))
|
||||
}
|
||||
|
||||
// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。
|
||||
// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。
|
||||
func TestTraeExchangeSendsRealClientID(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。
|
||||
// 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。
|
||||
status, body := h.beginAndCallback(map[string]string{
|
||||
"refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n == 0 {
|
||||
t.Fatal("token 直传场景必须真的调用 ExchangeToken")
|
||||
}
|
||||
t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit))
|
||||
}
|
||||
|
||||
// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。
|
||||
func TestTraeClientIDIsNotBlank(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) {
|
||||
t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " +
|
||||
"留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400")
|
||||
}
|
||||
}
|
||||
|
||||
// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论,
|
||||
// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。
|
||||
func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
// ① 不得再出现自己发明的交换(注释里说明历史的那句除外)
|
||||
if strings.Contains(src, `cerr = "Trae 授权码交换失败`) {
|
||||
t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," +
|
||||
"必然 400 且把真因掩盖成交换失败")
|
||||
}
|
||||
// ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定)
|
||||
if n := strings.Count(src, "traeCallback(plat,"); n < 2 {
|
||||
t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n)
|
||||
}
|
||||
// ③ PKCE 判定必须仍在 traeCallback 内
|
||||
if !strings.Contains(src, "本实现暂不支持该流程") {
|
||||
t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)")
|
||||
}
|
||||
}
|
||||
|
||||
// beginAndPollWithCallback 走**真实登录用的那条路径**:
|
||||
// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。
|
||||
//
|
||||
// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是
|
||||
// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的
|
||||
// trae 分支里。
|
||||
func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) {
|
||||
h.t.Helper()
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
|
||||
}
|
||||
sess := j["session"].(map[string]interface{})
|
||||
sid, _ := sess["id"].(string)
|
||||
nonce, _ := sess["nonce"].(string)
|
||||
if sid == "" {
|
||||
h.t.Fatalf("会话缺 id: %#v", sess)
|
||||
}
|
||||
|
||||
// 摆好 loopback 回调文件(内容 = 平台重定向带回的 query)
|
||||
rec := map[string]string{"state": nonce}
|
||||
for k, v := range cbQuery {
|
||||
rec[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(rec)
|
||||
cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json")
|
||||
if err := os.WriteFile(cbPath, b, 0600); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
// 告诉插件「这个会话的 loopback 接收器把结果写在这里了」
|
||||
if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{
|
||||
"session_id": sid, "file": cbPath,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("设置 loopback 文件: %v", err)
|
||||
}
|
||||
|
||||
res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid},
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
|
||||
// ★ PKCE 探测的候选来源必须覆盖参考的 5 个(含 authCodeInfo 为**纯 code 字符串**
|
||||
// 这一形态 —— 参考注释点名,解析不出 JSON 时必须回退到原始值)。
|
||||
//
|
||||
// 反例(我第一版就是错的):用 `q.code or q.authCode or jsonField(...)` 链,
|
||||
// 而 jsonField 取不到时返回**空串**;Lua 里空串是真值 ⇒ 链在第一个空串处短路,
|
||||
// 后面的来源永远取不到。
|
||||
func TestTraePkceDetectsPlainStringAuthCodeInfo(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
q map[string]string
|
||||
}{
|
||||
{"code 参数", map[string]string{"code": "C1"}},
|
||||
{"authCode 参数", map[string]string{"authCode": "C2"}},
|
||||
{"authCodeInfo 是 JSON", map[string]string{"authCodeInfo": `{"code":"C3"}`}},
|
||||
{"authCodeInfo 是 JSON(authCode)", map[string]string{"authCodeInfo": `{"authCode":"C4"}`}},
|
||||
{"authCodeInfo 是纯字符串", map[string]string{"authCodeInfo": "C5"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
status, body := h.beginAndPollWithCallback(tc.q)
|
||||
_ = status
|
||||
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
|
||||
t.Fatalf("PKCE 形态不得去调 ExchangeToken(打了 %d 次)", n)
|
||||
}
|
||||
if !strings.Contains(body, "PKCE") {
|
||||
t.Fatalf("应识别为 PKCE 并明确报出,实得 %q", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
258
internal/core/channels_trae_test.go
Normal file
258
internal/core/channels_trae_test.go
Normal file
@ -0,0 +1,258 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// traeHarness:TRAE callback 流的测试台。假上游模拟 ExchangeToken;
|
||||
// 回调用 InvokeHandler 直打 oauth_callback(Query 表 = 平台重定向回来的参数)。
|
||||
type traeHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
exchHit int32
|
||||
exchOK int32 // 1=成功 0=失败(atomic)
|
||||
}
|
||||
|
||||
func newTraeHarness(t *testing.T) *traeHarness {
|
||||
t.Helper()
|
||||
h := &traeHarness{t: t, exchOK: 1}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/cloudide/api/v3/trae/oauth/ExchangeToken" {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
n := atomic.AddInt32(&h.exchHit, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if atomic.LoadInt32(&h.exchOK) == 0 {
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprint(w, `{"error":"exchange down"}`)
|
||||
return
|
||||
}
|
||||
// 校验请求体形状(对齐参考 exchangeTraeCallback 的请求构造)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
for _, want := range []string{`"ClientID"`, `"RefreshToken":"RT-FROM-CALLBACK"`, `"ClientSecret":"-"`} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("ExchangeToken 请求体缺 %s: %s", want, body)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, `{"Token":"ACCESS-%d","RefreshToken":"RT-NEW-%d"}`, n, n)
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
// ExchangeToken 指到假上游(set_endpoints 既是运维能力也是测试缝)
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"callback": map[string]interface{}{
|
||||
"exchange_url": h.ts.URL + "/cloudide/api/v3/trae/oauth/ExchangeToken",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// beginAndCallback 发起登录(拿 nonce)后直接打 oauth_callback。
|
||||
// cbQuery 是回调的 query(不含 state,内部补);值即平台重定向回来的原始参数。
|
||||
func (h *traeHarness) beginAndCallback(cbQuery map[string]string) (int, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
|
||||
}
|
||||
sess := j["session"].(map[string]interface{})
|
||||
nonce := sess["nonce"].(string)
|
||||
|
||||
q := map[string]string{"state": nonce}
|
||||
for k, v := range cbQuery {
|
||||
q[k] = v
|
||||
}
|
||||
res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{
|
||||
Method: "GET", Query: q,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
|
||||
// TestTraeCallbackBehavior 是 TRAE callback 流的行为对齐测试。
|
||||
//
|
||||
// 决策表逐行搬自参考实现 parseTraeCallbackDetailed + exchangeTraeCallback
|
||||
// (trae-oauth.ts)。断言的是行为(凭据来源 / 错误文案 / 上游是否被调),不是实现细节。
|
||||
func TestTraeCallbackBehavior(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
|
||||
t.Run("行1 refreshToken直传_ExchangeToken换新", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.exchOK, 1)
|
||||
st, body := h.beginAndCallback(map[string]string{
|
||||
"refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"张三","TenantID":"t1"}`,
|
||||
})
|
||||
if st != 200 {
|
||||
t.Fatalf("应 200: %d %s", st, body)
|
||||
}
|
||||
// 凭据必须是 ExchangeToken 的新 Token,不是回调原值。
|
||||
// 凭据是 JSON(Trae 需 device_id/uid 供签到派生设备身份),故解出 access_token。
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
|
||||
var cred map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(v), &cred); err != nil {
|
||||
t.Fatalf("凭据应为 JSON(含 device_id/uid): %v (%q)", err, v)
|
||||
}
|
||||
if at, _ := cred["access_token"].(string); !strings.HasPrefix(at, "ACCESS-") {
|
||||
t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", at)
|
||||
}
|
||||
// uid 来自 userInfo.UserID —— 签到设备身份的派生种子,丢了就无法签到
|
||||
if cred["uid"] != "u1" {
|
||||
t.Fatalf("凭据应保留 uid=u1(签到设备身份的种子), 实得 %v", cred["uid"])
|
||||
}
|
||||
if cred["enterprise_id"] != "t1" {
|
||||
t.Fatalf("凭据应保留 TenantID, 实得 %v", cred["enterprise_id"])
|
||||
}
|
||||
// 设备身份来自登录会话(授权 URL 里下发的那对 hex32)
|
||||
if d, _ := cred["device_id"].(string); len(d) != 32 {
|
||||
t.Fatalf("凭据应带 32 位 device_id(签到轮换用), 实得 %q", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 无refreshToken_userJwtToken兜底_不打ExchangeToken", func(t *testing.T) {
|
||||
before := atomic.LoadInt32(&h.exchHit)
|
||||
st, body := h.beginAndCallback(map[string]string{
|
||||
"userJwt": `{"Token":"JWT-TOKEN-1","RefreshToken":""}`,
|
||||
})
|
||||
if st != 200 {
|
||||
t.Fatalf("应 200: %d %s", st, body)
|
||||
}
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
|
||||
var cred2 map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(v), &cred2); err != nil {
|
||||
t.Fatalf("凭据应为 JSON: %v (%q)", err, v)
|
||||
}
|
||||
if cred2["access_token"] != "JWT-TOKEN-1" {
|
||||
t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %v", cred2["access_token"])
|
||||
}
|
||||
if atomic.LoadInt32(&h.exchHit) != before {
|
||||
t.Fatal("无 refreshToken 不应调 ExchangeToken(参考实现分支2)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 只有code_PKCE流_明确报暂不支持", func(t *testing.T) {
|
||||
st, body := h.beginAndCallback(map[string]string{"code": "PKCE-CODE-1"})
|
||||
if st != 400 {
|
||||
t.Fatalf("PKCE 流应 400: %d %s", st, body)
|
||||
}
|
||||
if !strings.Contains(body, "PKCE") {
|
||||
t.Fatalf("错误应明确指向 PKCE 流, 实得 %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 空回调_报缺失", func(t *testing.T) {
|
||||
st, body := h.beginAndCallback(map[string]string{})
|
||||
if st != 400 {
|
||||
t.Fatalf("空回调应 400: %d %s", st, body)
|
||||
}
|
||||
if !strings.Contains(body, "refreshToken") {
|
||||
t.Fatalf("错误应指出缺什么, 实得 %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行5 ExchangeToken失败_透传阶段", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.exchOK, 0)
|
||||
defer atomic.StoreInt32(&h.exchOK, 1)
|
||||
st, body := h.beginAndCallback(map[string]string{"refreshToken": "RT-FROM-CALLBACK"})
|
||||
if st != 400 {
|
||||
t.Fatalf("exchange 失败应 400: %d %s", st, body)
|
||||
}
|
||||
if !strings.Contains(body, "ExchangeToken") {
|
||||
t.Fatalf("错误应指明 ExchangeToken 阶段, 实得 %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTraeCallbackPoolPath 回归:callback 流的**池路径**(UI 真实流程)。
|
||||
//
|
||||
// 与 buddy poll_login 的池路径同一缺陷:complete_login 不带 account_id 时,
|
||||
// 凭据落到与占位账号无关的 `channels:<plat>` 键,池账号永远 has_credential=false。
|
||||
// 此前只有 device-poll / state-poll 分支修了,callback 分支是漏网。
|
||||
func TestTraeCallbackPoolPath(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
atomic.StoreInt32(&h.exchOK, 1)
|
||||
|
||||
// UI 流程:先建占位账号,再带 account_id 发起登录
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"trae","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
|
||||
|
||||
st, body := h.invokeCallback(map[string]string{
|
||||
"state": nonce, "refreshToken": "RT-FROM-CALLBACK", "userInfo": `{"UserID":"u9","ScreenName":"池用户"}`,
|
||||
})
|
||||
if st != 200 {
|
||||
t.Fatalf("回调应 200: %d %s", st, body)
|
||||
}
|
||||
|
||||
// ① 凭据必须落在池账号的 ref
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true
|
||||
lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range lst.JSON.(map[string]interface{})["accounts"].([]interface{}) {
|
||||
a := raw.(map[string]interface{})
|
||||
if a["id"] == aid && a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invokeCallback 直打 oauth_callback(Query 已含 state)。
|
||||
func (h *traeHarness) invokeCallback(q map[string]string) (int, string) {
|
||||
res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{
|
||||
Method: "GET", Query: q,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
100
internal/core/channels_ttl_cache_test.go
Normal file
100
internal/core/channels_ttl_cache_test.go
Normal file
@ -0,0 +1,100 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 通用件:TTL 缓存 + 串行锁(照参考 ttl-cache.ts / serial-queue.ts)。
|
||||
//
|
||||
// 抽它们出来的理由:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"
|
||||
// 逻辑(远端目录闸门、余额 60s 缓存、登录轮询…)。手写版本各写各的,
|
||||
// 边界条件容易漏 —— 实测就漏了一个(见下面 ③)。
|
||||
func TestTtlCacheSemantics(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "__ttl_reset_probe", nil); err != nil {
|
||||
t.Fatalf("重置: %v", err)
|
||||
}
|
||||
|
||||
// ① 首次加载后命中缓存:loader 只该被调一次
|
||||
out, err := ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 60,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j["value"] != "v1" {
|
||||
t.Errorf("首次应加载出 v1,实得 %v", j["value"])
|
||||
}
|
||||
if n, _ := j["loads"].(float64); n != 1 {
|
||||
t.Errorf("首次后 loads 应为 1,实得 %v", n)
|
||||
}
|
||||
// 再取一次:命中缓存,loads 不变
|
||||
out2, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 60,
|
||||
})
|
||||
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if n, _ := j2["loads"].(float64); n != 1 {
|
||||
t.Errorf("命中缓存时不该再加载,loads 实得 %v", n)
|
||||
}
|
||||
|
||||
// ② 失效后必须能重新加载(包括同一秒内 —— 见 ③)
|
||||
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k",
|
||||
}); err != nil {
|
||||
t.Fatalf("失效: %v", err)
|
||||
}
|
||||
out3, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 60,
|
||||
})
|
||||
j3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
|
||||
// ⚠️ 这是实测踩到的坑:只清缓存、不清"在飞标记"时,同一秒内的重载会被
|
||||
// 去重逻辑挡住,返回 nil —— 表现为"刚清了缓存却拿不到值"。
|
||||
if j3["value"] == nil {
|
||||
t.Fatalf("失效后立刻重载必须拿到值(清缓存时也要清在飞标记),实得 nil")
|
||||
}
|
||||
if n, _ := j3["loads"].(float64); n != 2 {
|
||||
t.Errorf("失效后应重新加载(loads=2),实得 %v", n)
|
||||
}
|
||||
|
||||
// ③ TTL=0 视为立即过期:不该命中
|
||||
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k",
|
||||
}); err != nil {
|
||||
t.Fatalf("失效: %v", err)
|
||||
}
|
||||
_, _ = ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 0,
|
||||
})
|
||||
out5, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 0,
|
||||
})
|
||||
j5, _ := out5.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if n, _ := j5["loads"].(float64); n < 3 {
|
||||
t.Errorf("ttl=0 不该命中缓存(每次都要重载),loads 实得 %v", n)
|
||||
}
|
||||
|
||||
// ④ 串行锁:抢到 → 第二次抢不到 → 释放后能再抢
|
||||
a, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
|
||||
"ns": "s", "ttl": 60})
|
||||
ja, _ := a.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if ja["acquired"] != true {
|
||||
t.Errorf("首次应抢到,实得 %v", ja["acquired"])
|
||||
}
|
||||
b, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
|
||||
"ns": "s", "ttl": 60})
|
||||
jb, _ := b.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if jb["acquired"] != false {
|
||||
t.Errorf("持有期间第二次不该抢到(这就是串行的意义),实得 %v", jb["acquired"])
|
||||
}
|
||||
if _, err := ps.Call("channels", "__serial_release_probe", map[string]interface{}{
|
||||
"ns": "s"}); err != nil {
|
||||
t.Fatalf("释放: %v", err)
|
||||
}
|
||||
c, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
|
||||
"ns": "s", "ttl": 60})
|
||||
jc, _ := c.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if jc["acquired"] != true {
|
||||
t.Errorf("释放后应能再抢到,实得 %v", jc["acquired"])
|
||||
}
|
||||
}
|
||||
67
internal/core/channels_upstream_gaps_test.go
Normal file
67
internal/core/channels_upstream_gaps_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 本文件把「上游客观缺口」写成**可执行的判据**,而不是只留在文档里。
|
||||
//
|
||||
// 为什么需要:这些能力缺口多次被误判成"我们漏实现了"(我自己就误判了两轮),
|
||||
// 于是反复派人去"补"。判据在此把事实钉住:
|
||||
//
|
||||
// - qoder 加密端点(agent_chat_generation)需要 `qoder-auth-wasm.wasm`
|
||||
// —— 该二进制**在参考仓库里也不存在**(参考自己也是 readFileSync 一个
|
||||
// 它不提供的文件)。故我们走明文端点 `/model/v1/chat/completions`,
|
||||
// **不经过信封** ⇒ qoder-envelope 的解包能力在明文路径上无从生效。
|
||||
// - TRAE 的 PKCE 分支参考**明确不支持**(只有"探测并拒绝",无 code→token
|
||||
// 交换端点)⇒ 我们报"暂不支持"是与参考对齐的正确行为。
|
||||
//
|
||||
// 判据的作用是**防止误判**:任何人(包括未来的我)想"补"这些能力时,
|
||||
// 必须先让这个测试失败 —— 也就是先推翻事实,再动手。
|
||||
func TestUpstreamGapsAreDocumented(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) {
|
||||
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH。
|
||||
// ⚠ 断言 **URL 的两半**而不是那一整串字面量:本仓已把"路径从 base_url
|
||||
// 移到 endpoint"(内核 URL = base_url + endpoint)—— 继续钉死整串会让
|
||||
// 一个纯粹的形态重构把这条**语义**判据弄红,那是判据在咬自己。
|
||||
if !strings.Contains(s, `base_url = "https://api2-v2.qoder.sh"`) ||
|
||||
!strings.Contains(s, `endpoint = "/model/v1/chat/completions"`) {
|
||||
t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," +
|
||||
"则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," +
|
||||
"否则响应会被当成畸形数据")
|
||||
}
|
||||
// 明文端点没有信封层,故解包**不该**存在(存在反而是死代码)
|
||||
if strings.Contains(s, "unwrapQoderEnvelope") {
|
||||
t.Fatal("出现了 qoder 信封解包 —— 明文端点没有信封层。" +
|
||||
"要么端点已换成加密端点(那需要 wasm),要么这是死代码")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TRAE PKCE 明确报不支持(与参考一致)", func(t *testing.T) {
|
||||
if !strings.Contains(s, "本实现暂不支持该流程") {
|
||||
t.Fatal("TRAE 的 PKCE 分支必须明确报出「暂不支持」—— " +
|
||||
"参考 trae-oauth.ts 也只做探测+拒绝,没有 code→token 交换端点。" +
|
||||
"若改为尝试交换,会拿 code 去打要 RefreshToken 的 ExchangeToken(必然 400)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codearts 目录打两个签名端点(本轮补上)", func(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"opengw.developer.huaweicloud.com/api/v1/gateway/config",
|
||||
"snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin",
|
||||
`sign = "codearts"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("codearts 目录缺 %q —— 参考 fetchCodeArtsRemoteModels 打两个"+
|
||||
"**必须 AK/SK 签名**的端点,只打一个会少一半模型", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
38
internal/core/channels_utf8clip_test.go
Normal file
38
internal/core/channels_utf8clip_test.go
Normal file
@ -0,0 +1,38 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 行为验证(不只是静态断言):utf8Clip 对中文截断后必须是合法 UTF-8。
|
||||
func TestUtf8ClipBehaviour(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,不为此放宽)—— 能力增强不可用,已退回兜底表"
|
||||
for _, n := range []int{10, 40, 119, 120, 121, 400} {
|
||||
out, err := ps.Call("channels", "__utf8_clip_probe", map[string]interface{}{"s": s, "n": n})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := jsonOf(t, out)["out"].(string)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("n=%d 结果不是合法 UTF-8: %q", n, got)
|
||||
}
|
||||
if n < len(s) && len(got) > n {
|
||||
t.Errorf("n=%d 结果超出字节上限: %d", n, len(got))
|
||||
}
|
||||
if n >= len(s) && got != s {
|
||||
t.Errorf("n=%d 未超长时应原样返回", n)
|
||||
}
|
||||
t.Logf("n=%-4d → %q (valid=%v, bytes=%d)", n, got, utf8.ValidString(got), len(got))
|
||||
}
|
||||
}
|
||||
59
internal/core/channels_vnc_gpu_test.go
Normal file
59
internal/core/channels_vnc_gpu_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// VNC 一直白屏的真因(2026-10-14 定位)。
|
||||
//
|
||||
// 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium
|
||||
// **独立 GPU 进程**启动所需的系统调用 ⇒ 它起不来 ⇒ chromium 放弃并退出:
|
||||
//
|
||||
// ERROR:gpu_process_host.cc] GPU process launch failed: error_code=1002
|
||||
// FATAL:gpu_data_manager_impl_private.cc] GPU process isn't usable. Goodbye.
|
||||
//
|
||||
// 整个 chromium 几秒内退出 ⇒ **VNC 上永远白屏**,而插件因为只等 CDP 端口就报
|
||||
// 「已就绪」,把白屏谎报成成功。
|
||||
//
|
||||
// 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功。定位靠
|
||||
// `systemd-run` 逐 directive 二分 —— 只有 SystemCallFilter 与 ProtectSystem
|
||||
// 能复现,其余加固项无影响。
|
||||
//
|
||||
// 修法:`--in-process-gpu`(GPU 逻辑跑在浏览器进程内,不 fork)。
|
||||
func TestBrowserStackUsesInProcessGPU(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ① 必须带 --in-process-gpu,否则在加固环境下浏览器起不来
|
||||
if !strings.Contains(b, "--in-process-gpu") {
|
||||
t.Fatal("chromium 命令行必须带 --in-process-gpu —— 否则在 " +
|
||||
"SystemCallFilter=@system-service 的加固环境下 GPU 进程起不来、" +
|
||||
"浏览器自杀、VNC 永远白屏(真实事故,2026-10-14)")
|
||||
}
|
||||
// ② 必须给可写的 HOME/XDG:ProtectHome=yes 下 /root 只读,
|
||||
// chromium 建 dconf/ibus 目录会持续刷 permission denied 噪声
|
||||
for _, k := range []string{"XDG_CACHE_HOME=", "XDG_CONFIG_HOME="} {
|
||||
if !strings.Contains(b, k) {
|
||||
t.Errorf("chromium 命令行应设置 %s(加固环境下 /root 只读会产生噪声日志)", k)
|
||||
}
|
||||
}
|
||||
// ③ **存活探针**必须存在:端口在听 ≠ 浏览器可用。
|
||||
// 没有它时,"浏览器已死"会被谎报成"已就绪",运维打开 VNC 是白屏,
|
||||
// 还会以为是自己操作的问题。谎报比直接报错更坏。
|
||||
if !strings.Contains(b, "browserUtil.cdpAlive") {
|
||||
t.Error("起栈后必须做 CDP 存活探针 —— 只等端口就报就绪会把白屏谎报成成功")
|
||||
}
|
||||
if !strings.Contains(b, "browserUtil.readTail") {
|
||||
t.Error("启动失败必须把日志真因带给用户,而不是一句「启动失败」")
|
||||
}
|
||||
// ④ 失败必须回收整组进程(否则每次失败都留一套 Xvfb/x11vnc/websockify)
|
||||
idx := strings.Index(b, "浏览器启动后即退出")
|
||||
if idx < 0 {
|
||||
t.Fatal("应有「浏览器启动后即退出」的失败分支")
|
||||
}
|
||||
if !strings.Contains(b[idx:idx+600], "reapBrowser()") {
|
||||
t.Error("起栈失败必须先回收已起的进程(否则泄漏一套 Xvfb/x11vnc/websockify)")
|
||||
}
|
||||
}
|
||||
70
internal/core/channels_vnc_lifecycle_test.go
Normal file
70
internal/core/channels_vnc_lifecycle_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// VNC 会话生命周期的**前向引用**判据(真实 bug 的守卫)。
|
||||
//
|
||||
// `sweepSessions` 里调用了 reapLoopback / browserStack / reapBrowser,而这三个
|
||||
// 助手定义在**更后面**。Lua 的 `local` 只从定义处向后可见 ⇒ 未前向声明时它们是
|
||||
// **全局 nil**:加载期不报错,只有真正走到"会话过期回收"那条分支才炸:
|
||||
//
|
||||
// attempt to call global 'reapLoopback' (a nil value)
|
||||
//
|
||||
// 症状极具迷惑性:**第一次登录完全正常**(没有过期会话可回收),第二次才 500,
|
||||
// 且已经起来的浏览器栈**不会被回收**(reapBrowser 同样调不到)⇒ 进程与端口泄漏。
|
||||
// 本文件此前已因同一个坑踩过 12 次,故这里钉住"三者都必须有前向声明"。
|
||||
func TestSweepSessionsHelpersAreForwardDeclared(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ① 必须存在对这三个助手的前向声明
|
||||
if !strings.Contains(b, "local reapLoopback, browserStack, reapBrowser") {
|
||||
t.Fatal("sweepSessions 用到的三个助手必须**前向声明** —— 否则 " +
|
||||
"`local` 只从定义处向后可见,运行到该分支时是 nil 调用(第一次登录" +
|
||||
"正常、第二次 500,且浏览器栈不会被回收)")
|
||||
}
|
||||
// ② 定义处必须是**赋值**形态(绑定到那个 local),不能又是 `local function`
|
||||
// —— 那会遮蔽前向声明,效果与没声明完全相同。
|
||||
for _, fn := range []string{"browserStack", "reapBrowser", "reapLoopback"} {
|
||||
if strings.Contains(b, "local function "+fn+"(") {
|
||||
t.Errorf("%s 的定必须是 `%s = function(...)` 形式(绑定前向声明);"+
|
||||
"再用 `local function` 会另起一个 local,前向声明形同虚设", fn, fn)
|
||||
}
|
||||
if !strings.Contains(b, "\n"+fn+" = function(") {
|
||||
t.Errorf("找不到 %s 的赋值式定义", fn)
|
||||
}
|
||||
}
|
||||
// ③ 声明必须**先于**使用(行号比较):这是前向引用的核心
|
||||
decl := strings.Index(b, "local reapLoopback, browserStack, reapBrowser")
|
||||
use := strings.Index(b, "\n reapLoopback(id)\n")
|
||||
if decl < 0 || use < 0 {
|
||||
t.Fatalf("找不到声明(%d)或使用(%d)", decl, use)
|
||||
}
|
||||
if decl > use {
|
||||
t.Error("前向声明必须在**使用之前** —— 否则 nil 调用")
|
||||
}
|
||||
}
|
||||
|
||||
// 浏览器栈的**启动后存活**必须有真实判据(谎报"就绪"比直接报错更坏)。
|
||||
func TestBrowserStackVerifiesLivenessNotJustPort(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// CDP 真应答(端口在听 ≠ 服务可用)
|
||||
if !strings.Contains(b, "browserUtil.cdpAlive") {
|
||||
t.Error("必须用 CDP 真应答判活,不能只看端口")
|
||||
}
|
||||
// 进程真存活
|
||||
if !strings.Contains(b, "/proc/") {
|
||||
t.Error("必须检查 chromium 进程仍存活(/proc/<pid>)")
|
||||
}
|
||||
// 失败要把日志真因带出来(而不是一句"启动失败")
|
||||
if !strings.Contains(b, "browserUtil.readTail") {
|
||||
t.Error("启动失败必须带出日志真因")
|
||||
}
|
||||
}
|
||||
153
internal/core/channels_vnc_test.go
Normal file
153
internal/core/channels_vnc_test.go
Normal file
@ -0,0 +1,153 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通:
|
||||
// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口)
|
||||
// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。
|
||||
//
|
||||
// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。
|
||||
// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。
|
||||
func TestChannelsVncStackEndToEnd(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
|
||||
// 依赖预检:缺件则跳过(并让日志说明)
|
||||
depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("deps_status: %v", err)
|
||||
}
|
||||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"])
|
||||
}
|
||||
|
||||
// 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb",
|
||||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai",
|
||||
"models": ["v-model-1"]
|
||||
}]`)
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
if e, _ := m["browser_error"].(string); e != "" {
|
||||
t.Fatalf("浏览器栈未能启动: %s", e)
|
||||
}
|
||||
view, _ := m["view_url"].(string)
|
||||
if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") {
|
||||
t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view)
|
||||
}
|
||||
sess := m["session"].(map[string]interface{})
|
||||
if sess["cdp_port"] == nil {
|
||||
t.Fatal("会话应带 CDP 端口(取凭据用)")
|
||||
}
|
||||
|
||||
// ★ noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()),
|
||||
// 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。
|
||||
// 真实缺陷:传了站点根路径 ⇒ 路径被重复拼接,WebSocket 永远连不上,
|
||||
// 页面看似白屏:
|
||||
// .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify
|
||||
// 本判据必须解析**产品自己给出的 URL**:我曾在验证时手拼 URL 并省掉
|
||||
// path 参数(默认值恰好正确)⇒ 否定了真实缺陷。
|
||||
u, perr := url.Parse(view)
|
||||
if perr != nil {
|
||||
t.Fatalf("view_url 不可解析: %v", perr)
|
||||
}
|
||||
q := u.Query()
|
||||
wspath := q.Get("path")
|
||||
if wspath == "" {
|
||||
t.Fatal("view_url 必须显式声明 path=websockify:依赖 noVNC 隐式默认值" +
|
||||
"会在 URL 形状变化时静默破裂")
|
||||
}
|
||||
if strings.HasPrefix(wspath, "/") {
|
||||
t.Fatalf("path 必须是相对名(noVNC 相对 vnc.html 目录解析),"+
|
||||
"以 / 开头会被当相对路径而重复拼接,实得 %q", wspath)
|
||||
}
|
||||
resolved := u.ResolveReference(&url.URL{Path: wspath})
|
||||
pages := strings.TrimSuffix(u.Path, "/vnc.html")
|
||||
if resolved.Path != pages+"/websockify" {
|
||||
t.Fatalf("path 解析后应为 %q(同级 websockify),实得 %q —— 重复拼接即白屏根因",
|
||||
pages+"/websockify", resolved.Path)
|
||||
}
|
||||
|
||||
t.Logf("VNC 栈已起: %s → WebSocket %s (CDP %v)", view, resolved.Path, sess["cdp_port"])
|
||||
|
||||
// 收尾:回收浏览器栈(测试不留孤儿进程)
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。
|
||||
//
|
||||
// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids;
|
||||
// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存
|
||||
// (实测一次跑测试留下 4 个 orphan Xvfb)。
|
||||
//
|
||||
// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的
|
||||
// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。
|
||||
func TestChannelsVncNoOrphanOnFailure(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
|
||||
depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Skip("跳过:缺浏览器栈依赖")
|
||||
}
|
||||
// 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行;
|
||||
// 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 ——
|
||||
// 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb",
|
||||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"]
|
||||
}]`)
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" {
|
||||
t.Skipf("本机浏览器栈起不来(%s),跳过", e)
|
||||
}
|
||||
// 起来后立刻回收
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 回收后状态必须清零
|
||||
st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if st.JSON.(map[string]interface{})["running"] != false {
|
||||
t.Fatal("stop_browser 后 running 应为 false")
|
||||
}
|
||||
}
|
||||
203
internal/core/channels_xdisplay_test.go
Normal file
203
internal/core/channels_xdisplay_test.go
Normal file
@ -0,0 +1,203 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。
|
||||
//
|
||||
// 现场(2026-10-13):/tmp/.X<n>-lock 在 Xvfb 被 SIGKILL 后会留下(插件收尾
|
||||
// 就是 `kill -9`)。而旧选号逻辑是「锁/socket 存在就当被占」→ 每个被强杀的
|
||||
// 显示器**永久烧掉一个号**。反复跑测试/反复登录后 91 起的整段号被占满
|
||||
// (实测 41/41),于是 pickFree 扫不到空位 → `return start`(91) → Xvfb 报
|
||||
// “Server is already active for display 91”。
|
||||
// 表现为 TestChannelsVncStackEndToEnd 单独 1.07s 通过、全量跑 15s 超时失败。
|
||||
//
|
||||
// 判据是**行为**,分三步(注意:插件占用期间锁存在是**正确**的 —— Xvfb 自己
|
||||
// 会写锁;错的是「用完之后不清理」和「被别人的陈旧锁挡死」):
|
||||
// 1. 陈旧锁不得挡住选号(记录 PID 已死)→ 能正常起来,且不报 already active;
|
||||
// 2. 空闲号被选中后,锁里应是**真实存活的** PID(即插件真占用了它);
|
||||
// 3. 收栈之后,该号的锁与 socket 必须被清掉(这才是泄漏的根治)。
|
||||
func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
|
||||
const ghost = "999999" // 必然不存在的 PID
|
||||
// 造 3 个陈旧锁,并要求插件**复用**其中一个。
|
||||
// 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。
|
||||
//
|
||||
// ⚠️ 号段必须**跟着测试台的 XDISPLAY_BASE 走**(不是硬编码 91/92/93)。
|
||||
// 原因:生产 unit 带 PrivateTmp,它起的 Xvfb :91 的锁在服务自己的 /tmp 里,
|
||||
// 宿主测试看不到 ⇒ 测试会去抢 91 并失败。测试台因此把起点挪到 300
|
||||
// (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的
|
||||
// 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。
|
||||
base := 300
|
||||
if v := os.Getenv("LLMSPROXY_XDISPLAY_BASE"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
base = n
|
||||
}
|
||||
}
|
||||
ghosts := []int{base, base + 1, base + 2}
|
||||
for _, n := range ghosts {
|
||||
l := "/tmp/.X" + strconv.Itoa(n) + "-lock"
|
||||
if _, err := os.Stat(l); err == nil {
|
||||
t.Skipf("前置条件不满足:%s 已存在(先清理测试环境再跑)", l)
|
||||
}
|
||||
if err := os.WriteFile(l, []byte(ghost+"\n"), 0o644); err != nil {
|
||||
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
|
||||
}
|
||||
defer os.Remove(l)
|
||||
}
|
||||
lock := "/tmp/.X" + strconv.Itoa(base) + "-lock"
|
||||
sock := "/tmp/.X11-unix/X" + strconv.Itoa(base)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 12 个真实平台里已无 vnc 流,故注入一个夹具(夹具不写进生产目录)。
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "xdispfx", "name": "显示器夹具", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "http://127.0.0.1:18099/login",
|
||||
"base_url": "https://api.xdispfx.example/v1", "adapter": "openai",
|
||||
"models": ["x-model-1"]
|
||||
}]`)
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"xdispfx"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
j, _ := out.JSON.(map[string]interface{})
|
||||
if j == nil {
|
||||
t.Fatalf("无 JSON 响应 (status=%d)", out.Status)
|
||||
}
|
||||
msg, _ := j["browser_error"].(string)
|
||||
if strings.Contains(msg, "already active for display") {
|
||||
t.Fatalf("陈旧锁把选号挡死了(应回收):%s", msg)
|
||||
}
|
||||
if msg != "" {
|
||||
// 缺浏览器依赖起不来是允许的 —— 但陈旧锁仍必须被回收(不占号)。
|
||||
if _, err := os.Stat(lock); err == nil {
|
||||
t.Errorf("陈旧锁未被回收: %s(记录 PID %s 已不存在)", lock, ghost)
|
||||
}
|
||||
t.Skipf("浏览器栈未启动(与选号无关): %s", msg)
|
||||
}
|
||||
|
||||
// ② 插件确实占用了某个显示器,且锁里是**活着的** PID(不是那个幽灵)。
|
||||
st, err := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("browser_status: %v", err)
|
||||
}
|
||||
sess, _ := st.JSON.(map[string]interface{})["session"].(map[string]interface{})
|
||||
used := 0
|
||||
if sess != nil {
|
||||
if f, ok := sess["display"].(float64); ok {
|
||||
used = int(f)
|
||||
}
|
||||
}
|
||||
if used == 0 {
|
||||
t.Fatalf("会话未报告 display: %v", st.JSON)
|
||||
}
|
||||
// 关键断言:选中的必须是那 3 个陈旧号之一 —— 证明陈旧锁被**回收复用**了。
|
||||
// 只跳过不复用的实现(变异)会选 94,从而被抓到。
|
||||
reclaimed := false
|
||||
for _, n := range ghosts {
|
||||
if used == n {
|
||||
reclaimed = true
|
||||
}
|
||||
}
|
||||
if !reclaimed {
|
||||
t.Errorf("选中的 :%d 不是陈旧号 %v —— 陈旧锁没被回收复用(号段会被逐个烧掉,最终饿死)", used, ghosts)
|
||||
}
|
||||
if raw, err := os.ReadFile("/tmp/.X" + strconv.Itoa(used) + "-lock"); err == nil {
|
||||
pid := strings.TrimSpace(string(raw))
|
||||
if pid == ghost {
|
||||
t.Errorf("选中的显示器 :%d 锁里仍是幽灵 PID(陈旧锁没被换掉)", used)
|
||||
} else if pid != "" {
|
||||
if _, err := os.Stat("/proc/" + pid); err != nil {
|
||||
t.Errorf("选中的显示器 :%d 锁里 PID %s 不存在(记录与事实不符)", used, pid)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Logf(":%d 未生成锁文件(部分 Xvfb 行为,可接受)", used)
|
||||
}
|
||||
|
||||
// ③ 收栈后必须不留锁/socket —— 这条直接钉住「用 kill -9 收尾却不清理」的泄漏。
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
dl := "/tmp/.X" + strconv.Itoa(used) + "-lock"
|
||||
ds := "/tmp/.X11-unix/X" + strconv.Itoa(used)
|
||||
if _, err := os.Stat(dl); err == nil {
|
||||
t.Errorf("收栈后锁未清理: %s(下次会把这个号误判为被占 → 号被永久烧掉)", dl)
|
||||
_ = os.Remove(dl)
|
||||
}
|
||||
if _, err := os.Stat(ds); err == nil {
|
||||
t.Errorf("收栈后 socket 未清理: %s", ds)
|
||||
_ = os.Remove(ds)
|
||||
}
|
||||
_ = sock
|
||||
}
|
||||
|
||||
// 反向判据:**活着的**占用不得被回收(否则会把别人正在用的显示器抢走)。
|
||||
func TestXDisplaySelectionKeepsLiveLocks(t *testing.T) {
|
||||
const disp = 92
|
||||
lock := "/tmp/.X" + strconv.Itoa(disp) + "-lock"
|
||||
if _, err := os.Stat(lock); err == nil {
|
||||
t.Skipf("前置条件不满足:%s 已存在", lock)
|
||||
}
|
||||
// 用一个**真实存活**的 PID(本进程)写锁。
|
||||
if err := os.WriteFile(lock, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
|
||||
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
|
||||
}
|
||||
defer os.Remove(lock)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "xdispfx2", "name": "显示器夹具2", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "http://127.0.0.1:18097/login",
|
||||
"base_url": "https://api.xdispfx2.example/v1", "adapter": "openai",
|
||||
"models": ["x-model-2"]
|
||||
}]`)
|
||||
defer func() {
|
||||
_, _ = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
})
|
||||
}()
|
||||
|
||||
if _, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"xdispfx2"}`, Role: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
|
||||
// 记录的是活 PID → 该锁必须原样保留(不得抢占别人正在用的显示器)。
|
||||
raw, err := os.ReadFile(lock)
|
||||
if err != nil {
|
||||
t.Fatalf("活锁被误回收: %s(记录的是真实存活 PID,不得回收)", lock)
|
||||
}
|
||||
if got := strings.TrimSpace(string(raw)); got != strconv.Itoa(os.Getpid()) {
|
||||
t.Errorf("活锁内容被改动: %q(应保持原样)", got)
|
||||
}
|
||||
}
|
||||
285
internal/core/channels_zcode_3012_test.go
Normal file
285
internal/core/channels_zcode_3012_test.go
Normal file
@ -0,0 +1,285 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ ZCode 3012 的判据必须是**多形态**的,且误判不是"文案不好看"——
|
||||
// 领取路径会据此**短路**(跳过本可领取的 plan),而限流与风控的用户动作
|
||||
// **完全相反**(稍后重试 vs 别重试)。
|
||||
//
|
||||
// 本判据逐行搬自参考 zcode-diagnostics.ts 的对抗性审计实测矩阵
|
||||
// (2026-10-05 自审 + 审计打出的真实缺陷)。
|
||||
//
|
||||
// 为什么用**共用模块**验:这段逻辑被领取路径(插件主体)与推理路径
|
||||
// (channels-zcode 适配器)共用,任一侧漂移都会让"请勿连续重试"只出现在
|
||||
// 一条路径上。判据直接打共用模块的导出,两边用的是同一份源码文本。
|
||||
func TestZcode3012ClassificationMatrix(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
// ── 真风控(必须命中)──
|
||||
{"上游真实文案(带码)", `{"code":3012,"msg":"request has been blocked due to unusual activity."}`, true},
|
||||
{"码是纯数字字符串", `{"code":"3012","msg":"x"}`, true},
|
||||
{"仅正文语义(无数字)", `request has been blocked due to unusual activity.`, true},
|
||||
{"3012 与 block 紧邻", `3012 blocked by waf`, true},
|
||||
|
||||
// ── 必须**不**命中(误判会让用户做错事)──
|
||||
{"13012 不是 3012", `{"code":13012}`, false},
|
||||
{"标识符 plan-3012-trust", `plan-3012-trust`, false},
|
||||
{"标识符 trust_3012", `trust_3012`, false},
|
||||
{"字母包裹 x3012y", `x3012y`, false},
|
||||
{"限流的毫秒数", `too many requests, retry after 3012 ms`, false},
|
||||
{"HTML 里的 3012", `<h1>Error 3012</h1>upstream connect timeout`, false},
|
||||
{"限流+block 距 14", `rate limited: request blocked, retry after 3012 ms`, false},
|
||||
{"block 距 12", `temporarily blocked; see error 3012`, false},
|
||||
{"额度码 1005", `{"code":1005,"msg":"quota"}`, false},
|
||||
|
||||
// ── 隔离「词边界」规则:这些用例同时满足"block 紧邻",
|
||||
// 只有词边界能区分 ⇒ 裸子串实现会把前三个判成风控。
|
||||
// (最初的矩阵没覆盖这一组,变异测试因此没咬住 —— 补上后才有效。)
|
||||
{"标识符+block 紧邻", `plan-3012 blocked`, false},
|
||||
{"字母包裹+block 紧邻", `x3012 blocked`, false},
|
||||
{"下划线+block 紧邻", `trust_3012 blocked`, false},
|
||||
{"独立词+block 紧邻", `3012 blocked`, true},
|
||||
{"13012 里的片段+block 紧邻", `13012 blocked`, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := h.callZD("hasUnusualActivity", tc.body)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasUnusualActivity(%q) = %v, want %v\n"+
|
||||
"(误判的后果不是文案问题:领取路径会据此短路,且限流='稍后重试'、\n"+
|
||||
" 风控='别重试',两者用户动作完全相反)", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 时间差格式化必须逐档单独算:参考初版把分钟档写成 `floor(seconds % 60)`,
|
||||
// 于是 90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)。
|
||||
// 该错误是在**真实探针输出**里发现的,纯函数单测当时没覆盖 ≥60 秒的区间。
|
||||
func TestZcodeFormatGapDoesNotMisrenderMinutes(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
for _, tc := range []struct {
|
||||
ms int
|
||||
want string
|
||||
}{
|
||||
{90000, "1分30秒"}, // 参考踩过的坑:错法会得到 "30分30秒"
|
||||
{59000, "59.0s"}, // 不足一分钟
|
||||
{60000, "1分"}, // 整分钟
|
||||
{3600000, "1小时"}, // 整小时
|
||||
{3900000, "1小时5分"}, // 小时+分
|
||||
{500, "500ms"},
|
||||
} {
|
||||
got := h.callZDString("formatGap", tc.ms)
|
||||
if got != tc.want {
|
||||
t.Errorf("formatGap(%d) = %q, want %q", tc.ms, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 诊断行**绝不能含凭据片段**(参考的设计红线之一)。
|
||||
//
|
||||
// 账号只以"进程内自增序号"出现。判据用「传入的账号 id 的每个子串都不得出现在
|
||||
// 文案里」来钉死这条 —— 不是"看起来没有"。
|
||||
func TestZcodeDiagnosticLeaksNoCredentials(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
const secretID = "zcode-abcdef1234567890-SECRET"
|
||||
h.callZDAction("accountOrdinal", secretID)
|
||||
h.callZDAction2("noteRequestSent", secretID, 1000)
|
||||
h.callZDAction2("noteRequestOk", secretID, 1100)
|
||||
|
||||
diag := h.callZDString("formatDiagnostic", map[string]interface{}{
|
||||
"accountId": secretID,
|
||||
"sentAt": 1200,
|
||||
"now": 1300,
|
||||
"status": 405,
|
||||
})
|
||||
if diag == "" {
|
||||
t.Fatal("formatDiagnostic 返回空")
|
||||
}
|
||||
// 账号 id 的每个 >=4 字符子串都不得出现
|
||||
for i := 0; i+4 <= len(secretID); i++ {
|
||||
if strings.Contains(diag, secretID[i:i+4]) {
|
||||
t.Fatalf("诊断行泄漏账号 id 片段 %q:%s", secretID[i:i+4], diag)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(diag, "账号#") {
|
||||
t.Fatalf("诊断行应含「账号#序号」形态:%s", diag)
|
||||
}
|
||||
t.Logf("✓ 诊断行无凭据片段: %s", diag)
|
||||
}
|
||||
|
||||
// ★ 3012 的文案必须**共用**(推理 + 领取两条路径),且必须含"请勿连续重试"。
|
||||
//
|
||||
// 3012 不是"重试就能过"(那是 3007),而是**有账号冷却惩罚**:
|
||||
// 30 分钟起步,反复触发升级到 24 小时乃至停用。
|
||||
func TestZcode3012MessageIsSharedAndWarnsAboutRetries(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
msg := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{
|
||||
"subject": "领取 plan-x",
|
||||
"rawResponse": "request has been blocked",
|
||||
})
|
||||
for _, want := range []string{"3012", "请勿连续重试", "冷却惩罚", "领取 plan-x"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("风控文案缺 %q:%s", want, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 空 subject/diagnostic/rawResponse 时不得留下空壳("本机诊断:"后面啥都没有)
|
||||
bare := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{})
|
||||
if strings.Contains(bare, "本机诊断:\n") || strings.Contains(bare, "原始响应:\n") {
|
||||
t.Fatalf("空参数不应留下空壳段:%q", bare)
|
||||
}
|
||||
t.Logf("✓ 文案: %s", strings.SplitN(msg, "\n", 2)[0])
|
||||
}
|
||||
|
||||
// ★ 请求体形态诊断必须读**真正要发出去的对象**,而不是常量。
|
||||
//
|
||||
// 常量只能证明"源码里写了多少字",证明不了"线上发出去多少字"。
|
||||
// 两者一旦不一致(例如某条改动把 system 顶掉了),只有读实际值才看得出来
|
||||
// —— 它会**如实显示 0 字符**,而不是拿常量里的 2898 把问题盖住。
|
||||
func TestZcodeRequestShapeReadsActualObject(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
// 用真实的适配器产出 → 再诊断它(而不是手搓一个假 system)
|
||||
out, err := h.transformRequest(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := jsonUnmarshal(out, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shape := h.callZDMap("describeRequestShape", req["system"], req["messages"])
|
||||
if shape == nil {
|
||||
t.Fatal("describeRequestShape 返回空")
|
||||
}
|
||||
chars, _ := shape["identityChars"].(float64)
|
||||
if chars < 2800 {
|
||||
t.Fatalf("身份块实测字符数 %v < 2800(诊断必须读实际值)", chars)
|
||||
}
|
||||
if shape["hasDateBlock"] != true {
|
||||
t.Fatalf("应检测到日期块,实得 %v", shape["hasDateBlock"])
|
||||
}
|
||||
if shape["firstRole"] != "user" {
|
||||
t.Fatalf("firstRole 应为 user,实得 %v", shape["firstRole"])
|
||||
}
|
||||
t.Logf("✓ 形态诊断: 块数=%v 身份块=%v 日期块=%v role=%v",
|
||||
shape["systemBlocks"], chars, shape["hasDateBlock"], shape["firstRole"])
|
||||
|
||||
// 反例:空 system ⇒ 必须**如实显示 0**(不能被常量盖住)
|
||||
empty := h.callZDMap("describeRequestShape", []interface{}{}, req["messages"])
|
||||
if c, _ := empty["identityChars"].(float64); c != 0 {
|
||||
t.Fatalf("空 system 时 identityChars 必须为 0(如实反映实际值),实得 %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness 助手:经共用的探针打**那一份** ZD 实现 ──
|
||||
//
|
||||
// 走探针而不是让测试自己 load 一份副本:判据必须打真实被两条路径共用的实现,
|
||||
// 自己 load 副本会退化成"各自测各自的",共用一旦漂移就测不出来。
|
||||
|
||||
func (h *zcodeIdentityHarness) callZD(fn string, a interface{}) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
v, ok := m["result"].(bool)
|
||||
if !ok {
|
||||
h.t.Fatalf("%s 返回非布尔: %#v", fn, m["result"])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDString(fn string, a interface{}) string {
|
||||
h.t.Helper()
|
||||
var res interface{}
|
||||
var err error
|
||||
if opts, ok := a.(map[string]interface{}); ok {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "opts": opts})
|
||||
} else if fn == "formatGap" {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
} else {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
}
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
s, _ := m["result"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// callZDAction / callZDAction2:调"动作型"探针(不返回结果,只看没报错)。
|
||||
func (h *zcodeIdentityHarness) callZDAction(fn string, a interface{}) {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDAction2(fn string, a interface{}, a2 int) {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a, "a2": a2})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDMap(fn string, args ...interface{}) map[string]interface{} {
|
||||
h.t.Helper()
|
||||
var res interface{}
|
||||
var err error
|
||||
if len(args) == 2 {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "system": args[0], "messages": args[1]})
|
||||
} else {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": args[0]})
|
||||
}
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
out, _ := m["result"].(map[string]interface{})
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonUnmarshal 是本文件用的小助手(避免与其它测试文件的导入冲突)。
|
||||
func jsonUnmarshal(s string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(s), v)
|
||||
}
|
||||
404
internal/core/channels_zcode_identity_test.go
Normal file
404
internal/core/channels_zcode_identity_test.go
Normal file
@ -0,0 +1,404 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ ZCode 的身份块是 3012 准入的**唯一开关**。
|
||||
//
|
||||
// 参考实测(三个账号、17 次请求):system 缺官方身份块结构时,上游直接
|
||||
//
|
||||
// HTTP 405 + {"code":3012,"msg":"...unusual activity."}
|
||||
//
|
||||
// 且与 HTTP 头、版本号、请求频率、多轮历史、tools 声明、运行时**全部无关**。
|
||||
//
|
||||
// 判据矩阵(照参考,逐条锁定):
|
||||
//
|
||||
// 无 system → 3012
|
||||
// 仅 cliPrefix(42 字符) → 3012
|
||||
// cliPrefix + stable(约 2898 字符)→ 200
|
||||
//
|
||||
// ⇒ 判据是「身份块**结构**是否存在」,不是字符数凑够。
|
||||
//
|
||||
// 本判据看**适配器真正产出给上游的请求体**(transform_request 的返回值),
|
||||
// 而不是"适配器装了没"这类内部信号。
|
||||
func TestZcodeAdapterInjectsOfficialIdentityBlocks(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
out, err := h.transformRequest(`{"model":"glm-5.3-flash","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatalf("产出不是 JSON: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// ① system 必须是**块数组**(官方形态),不是纯字符串
|
||||
sys, ok := req["system"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("system 必须是块数组(上游检查的是这个结构),实得 %T: %v",
|
||||
req["system"], req["system"])
|
||||
}
|
||||
if len(sys) < 3 {
|
||||
t.Fatalf("system 块数至少 3(cliPrefix + stable + Environment),实得 %d", len(sys))
|
||||
}
|
||||
|
||||
// ② block[0] 必须是官方 CLI 前缀(准入必需)
|
||||
b0, _ := sys[0].(map[string]interface{})
|
||||
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
|
||||
t.Fatalf("block[0] 必须是官方 CLI 前缀(逐字),实得 %q", got)
|
||||
}
|
||||
|
||||
// ③ block[1] 必须是官方 stable 段(准入必需),长度对得上
|
||||
b1, _ := sys[1].(map[string]interface{})
|
||||
stable, _ := b1["text"].(string)
|
||||
if len(stable) < 2800 {
|
||||
t.Fatalf("block[1](stable 段)长度 %d < 2800 —— 身份块不完整会被判为裸请求(3012)",
|
||||
len(stable))
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"You are an interactive ZCode agent",
|
||||
"# Harness",
|
||||
"# ZCode Desktop Context",
|
||||
} {
|
||||
if !strings.Contains(stable, marker) {
|
||||
t.Fatalf("stable 段缺 %q —— 身份块被截断/改写", marker)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ block[2] 是 Environment 段(官方每会话都发;不发则模型不知 cwd/平台)
|
||||
b2, _ := sys[2].(map[string]interface{})
|
||||
env, _ := b2["text"].(string)
|
||||
if !strings.HasPrefix(env, "# Environment") {
|
||||
t.Fatalf("block[2] 必须是 Environment 段,实得 %q", env[:zcodeMin(40, len(env))])
|
||||
}
|
||||
for _, marker := range []string{"Primary working directory", "Platform", "powered by the model named"} {
|
||||
if !strings.Contains(env, marker) {
|
||||
t.Fatalf("Environment 段缺 %q:%s", marker, env)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 总长度必须达到参考声明的量级(2898)
|
||||
total := len("You are ZCode, an interactive coding agent") + len(stable)
|
||||
if total < 2800 {
|
||||
t.Fatalf("身份块合计 %d 字符 < 2800(参考实测的准入值约 2898)", total)
|
||||
}
|
||||
|
||||
// ⑥ 日期块:插到第一条 user 消息的 content **数组**最前(官方形态)
|
||||
msgs, _ := req["messages"].([]interface{})
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("messages 为空")
|
||||
}
|
||||
m0, _ := msgs[0].(map[string]interface{})
|
||||
content, ok := m0["content"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("首条 user 的 content 必须是**块数组**(拼进字符串会改变结构、仍被判裸请求),实得 %T",
|
||||
m0["content"])
|
||||
}
|
||||
c0, _ := content[0].(map[string]interface{})
|
||||
if got, _ := c0["text"].(string); !strings.Contains(got, "<system-reminder>") {
|
||||
t.Fatalf("content[0] 必须是日期块(<system-reminder>),实得 %q", got)
|
||||
}
|
||||
|
||||
t.Logf("✓ 身份块注入正确:块数=%d stable=%d 合计=%d", len(sys), len(stable), total)
|
||||
}
|
||||
|
||||
// ★ 调用方的 system 必须**追加在最后**:上游检查看的是**前缀结构**,
|
||||
// 官方身份块必须处在开头位置。若被前置会让身份块不在前缀而吃 3012。
|
||||
func TestZcodeCallerSystemIsAppendedLast(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
out, err := h.transformRequest(
|
||||
`{"model":"glm-5.3-flash","messages":[{"role":"system","content":"CALLER_PROMPT_XYZ"},{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sys, _ := req["system"].([]interface{})
|
||||
if len(sys) < 4 {
|
||||
t.Fatalf("带调用方 system 时应为 4 块(3 官方 + 1 调用方),实得 %d", len(sys))
|
||||
}
|
||||
last, _ := sys[len(sys)-1].(map[string]interface{})
|
||||
lt, _ := last["text"].(string)
|
||||
if !strings.Contains(lt, "CALLER_PROMPT_XYZ") {
|
||||
t.Fatalf("调用方 system 必须在**最后一块**(上游看前缀结构;前置会让身份块不在前缀)")
|
||||
}
|
||||
// 且前三块仍是官方块(身份块没有被调用方内容挤走)
|
||||
b0, _ := sys[0].(map[string]interface{})
|
||||
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
|
||||
t.Fatalf("block[0] 必须仍是官方 CLI 前缀,实得 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 适配器自检出口:身份块长度与 anthropic 基座装载状态。
|
||||
// 「装了但身份块是空的」这类问题(转义/截断/基座读不到)没有它只能靠猜。
|
||||
func TestZcodeAdapterProbeReportsIdentityLength(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
zc, ok := j["zcode"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针未报告 zcode 适配器装载结果: %v", j)
|
||||
}
|
||||
if zc["ok"] != true {
|
||||
t.Fatalf("zcode 适配器装载失败: %v", zc["err"])
|
||||
}
|
||||
}
|
||||
|
||||
// zcodeIdentityHarness:加载插件(它会**自装** channels-zcode 适配器),
|
||||
// 然后直接调那个适配器的 transform_request。
|
||||
//
|
||||
// 直接调已装载的适配器(而不是自己去 load 源码)更接近真实路径:判据验的是
|
||||
// 「上游实际收到的请求体」,包含前导注入、桥的装载等全部环节。
|
||||
type zcodeIdentityHarness struct {
|
||||
t *testing.T
|
||||
c *Core
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newZcodeIdentityHarness(t *testing.T) *zcodeIdentityHarness {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &zcodeIdentityHarness{t: t, c: c, ps: ps}
|
||||
}
|
||||
|
||||
// transformRequest 调**已自装的** channels-zcode 适配器的 transform_request。
|
||||
func (h *zcodeIdentityHarness) transformRequest(body string) (string, error) {
|
||||
h.t.Helper()
|
||||
vm := h.c.VM()
|
||||
if vm == nil {
|
||||
h.t.Fatal("VM 不可用")
|
||||
}
|
||||
return vm.Transform("channels-zcode", "transform_request", body)
|
||||
}
|
||||
|
||||
func zcodeMin(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ★ 身份块注入不得破坏协议转换:工具调用、图像、流式帧仍必须走 anthropic 那一份。
|
||||
//
|
||||
// 为什么必须验:本适配器的手法是把 anthropic.lua 的源码读进来执行、只覆盖
|
||||
// transform_request。若「读不到基座」或「覆盖时丢了字段」,协议转换会静默退化
|
||||
// —— 表现为工具调用参数丢失、图像块消失,而身份块看起来完全正常。
|
||||
func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
t.Run("工具定义与工具调用存活", func(t *testing.T) {
|
||||
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":"go"}],` +
|
||||
`"tools":[{"type":"function","function":{"name":"get_weather",` +
|
||||
`"description":"d","parameters":{"type":"object","properties":{"c":{"type":"string"}}}}}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// anthropic 形态:tools[].name / input_schema(顶层,不是嵌套在 function 里)
|
||||
if !strings.Contains(out, `"get_weather"`) {
|
||||
t.Fatalf("工具定义在转换中丢失(identity 注入不该影响它):%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"input_schema"`) {
|
||||
t.Fatalf("工具 schema 未转成 anthropic 形态(input_schema 缺失):%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("多模态图像块存活", func(t *testing.T) {
|
||||
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":[` +
|
||||
`{"type":"text","text":"what"},` +
|
||||
`{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}]}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) || !strings.Contains(out, `"base64"`) {
|
||||
t.Fatalf("图像块在转换中丢失:%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("基座装载状态可见", func(t *testing.T) {
|
||||
// 读适配器的自检出口:base_loaded 必须为 true(否则身份块虽在、
|
||||
// 协议转换已退化为透传)
|
||||
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
zc := j["zcode"].(map[string]interface{})
|
||||
if zc["ok"] != true {
|
||||
t.Fatalf("zcode 适配器未装载: %v", zc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ★ 基座(anthropic.lua)读不到时必须**响亮失败**,不得静默退化。
|
||||
//
|
||||
// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块
|
||||
// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型
|
||||
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
|
||||
//
|
||||
// 判据看的是**适配器的真实产出**:把适配器目录指到空目录(基座不可得),
|
||||
// transform_request 必须返回错误而不是原样透传。
|
||||
func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
|
||||
// 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := c.VM()
|
||||
if vm == nil {
|
||||
t.Fatal("VM 不可用")
|
||||
}
|
||||
// 插件自装时若基座读不到,适配器仍应装载(身份块是静态的),
|
||||
// 但调用必须报错
|
||||
out, err := vm.Transform("channels-zcode", "transform_request",
|
||||
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err == nil {
|
||||
t.Fatalf("基座不可得时必须报错,却成功产出了:%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "基座") {
|
||||
t.Fatalf("错误信息应点明基座问题(可定位),实得 %v", err)
|
||||
}
|
||||
t.Logf("✓ 基座缺失时响亮失败: %v", err)
|
||||
}
|
||||
|
||||
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。
|
||||
//
|
||||
// 不判的后果:同一请求重试/多次 transform 时日期块叠成多层,改变 content 的
|
||||
// 前缀结构(上游检查的正是结构)。参考 withContextPrefix 有 startsWithSystemReminder。
|
||||
func TestZcodeContextPrefixIsIdempotent(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
// 已是插入后的形态(首条 user 的 content[0] 就是 system-reminder)
|
||||
in := `{"model":"m","messages":[{"role":"user","content":[` +
|
||||
`{"type":"text","text":"<system-reminder>\nOLD\n</system-reminder>"},` +
|
||||
`{"type":"text","text":"hi"}]}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]interface{})
|
||||
content := msgs[0].(map[string]interface{})["content"].([]interface{})
|
||||
n := 0
|
||||
for _, c := range content {
|
||||
if b, ok := c.(map[string]interface{}); ok {
|
||||
if s, _ := b["text"].(string); strings.Contains(s, "<system-reminder>") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("日期块必须幂等(已存在则不重复插),实得 %d 个 system-reminder 块", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 日期块只处理**第一条**消息,且它必须 role == "user"。
|
||||
//
|
||||
// 反例(我第一版就是这样):往后扫第一个 user 消息 —— 若首条是 assistant,
|
||||
// 官方行为是**不动**,而不是去找后面那条 user。判据钉住这个差别。
|
||||
func TestZcodeContextPrefixOnlyTouchesFirstMessage(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
t.Run("首条不是 user 时不动", func(t *testing.T) {
|
||||
in := `{"model":"m","messages":[` +
|
||||
`{"role":"assistant","content":"opening"},` +
|
||||
`{"role":"user","content":"hi"}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]interface{})
|
||||
// ⚠ 注意:文本会被 anthropic 基座转成**块数组**(协议转换的正常行为),
|
||||
// 所以这里不能断言 content 仍是字符串 —— 我第一版就这么断言,把正确的
|
||||
// 实现判成了错。判据要验的是「**日期块**有没有被插进来」这个事实。
|
||||
noReminder := func(content interface{}) bool {
|
||||
switch c := content.(type) {
|
||||
case string:
|
||||
return !strings.Contains(c, "<system-reminder>")
|
||||
case []interface{}:
|
||||
for _, e := range c {
|
||||
if b, ok := e.(map[string]interface{}); ok {
|
||||
if t, _ := b["text"].(string); strings.Contains(t, "<system-reminder>") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 首条(assistant)不该被插日期块
|
||||
if !noReminder(msgs[0].(map[string]interface{})["content"]) {
|
||||
t.Fatal("首条是 assistant 时不该插日期块(官方只看第一条且要求 role=user)")
|
||||
}
|
||||
// 后面的 user 也**不该**被插(官方不往后找)
|
||||
if !noReminder(msgs[1].(map[string]interface{})["content"]) {
|
||||
t.Fatal("日期块被插到了后面的 user 消息上 —— 官方只看第一条")
|
||||
}
|
||||
// 且原文本内容必须保留(转换不能丢内容)
|
||||
b, _ := json.Marshal(msgs[0])
|
||||
if !strings.Contains(string(b), "opening") {
|
||||
t.Fatal("首条 assistant 的内容丢了")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("首条是 user 时插入", func(t *testing.T) {
|
||||
in := `{"model":"m","messages":[{"role":"user","content":"hi"}]}`
|
||||
out, err := h.transformRequest(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := req["messages"].([]interface{})
|
||||
content, ok := msgs[0].(map[string]interface{})["content"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("首条 user 的 content 应升级为块数组")
|
||||
}
|
||||
b0 := content[0].(map[string]interface{})
|
||||
if s, _ := b0["text"].(string); !strings.Contains(s, "<system-reminder>") {
|
||||
t.Fatalf("首条 user 的 content[0] 必须是日期块,实得 %q", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
188
internal/core/channels_zcode_test.go
Normal file
188
internal/core/channels_zcode_test.go
Normal file
@ -0,0 +1,188 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。
|
||||
//
|
||||
// 参考语义(zcode-login.ts 逐行):
|
||||
//
|
||||
// ① POST /api/v1/oauth/cli/init → {flow_id, authorize_url, poll_interval_sec}
|
||||
// Authorization 是**本地造的会话密钥**(不是用户凭据)
|
||||
// ② 用户在浏览器打开 authorize_url
|
||||
// ③ GET /api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
|
||||
// 判据:4xx(除 408/429)= 终态失败;5xx/网络错误 = 继续等;
|
||||
// code!=0 或 data 缺失 = pending;status=pending = 继续
|
||||
//
|
||||
// 此前标 flow="manual"(理由"要解 captcha"),但该授权流**完全不涉及 captcha**
|
||||
// —— captcha 属于另一条已废弃的旁路(读取本机官方客户端数据)。
|
||||
type zcodeHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
polls int32
|
||||
readyAt int32
|
||||
gotInit map[string]interface{}
|
||||
gotAuth string
|
||||
}
|
||||
|
||||
func newZcodeHarness(t *testing.T) *zcodeHarness {
|
||||
t.Helper()
|
||||
h := &zcodeHarness{t: t, readyAt: 2}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == "POST" && r.URL.Path == "/api/v1/oauth/cli/init":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &h.gotInit)
|
||||
h.gotAuth = r.Header.Get("Authorization")
|
||||
fmt.Fprint(w, `{"code":0,"data":{"flow_id":"FLOW-1","poll_token":"PT",
|
||||
"authorize_url":"https://bigmodel.cn/login?appId=zcode&redirect=x",
|
||||
"expires_at":9999999999,"poll_interval_sec":2}}`)
|
||||
case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v1/oauth/cli/poll/"):
|
||||
if !strings.HasSuffix(r.URL.Path, "FLOW-1") {
|
||||
fmt.Fprint(w, `{"code":1,"msg":"unknown flow"}`)
|
||||
return
|
||||
}
|
||||
n := atomic.AddInt32(&h.polls, 1)
|
||||
if n < atomic.LoadInt32(&h.readyAt) {
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"pending"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"ready",
|
||||
"token":"ZCODE-JWT-1",
|
||||
"user":{"user_id":"u7","name":"zcode-dev"},
|
||||
"bigmodel":{"access_token":"BM-AT-1","refresh_token":"BM-RT-1"}}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "zcode",
|
||||
"initpoll": map[string]interface{}{
|
||||
"init_url": h.ts.URL + "/api/v1/oauth/cli/init",
|
||||
"poll_url": h.ts.URL + "/api/v1/oauth/cli/poll/{flow_id}",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *zcodeHarness) begin() (map[string]interface{}, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
func (h *zcodeHarness) pollOnce(sid string) map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestZcodeInitPollBehavior 逐行锁定参考的 ZCode 授权流语义。
|
||||
func TestZcodeInitPollBehavior(t *testing.T) {
|
||||
h := newZcodeHarness(t)
|
||||
|
||||
t.Run("行1 init带本地会话密钥与provider", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
j, _ := h.begin()
|
||||
if h.gotInit["provider"] != "bigmodel" {
|
||||
t.Fatalf("init body 应带 provider=bigmodel, 实得 %v", h.gotInit)
|
||||
}
|
||||
// Authorization 是我们自己生成的会话密钥(Bearer 后非空),不是用户凭据
|
||||
if !strings.HasPrefix(h.gotAuth, "Bearer ") || len(h.gotAuth) < 20 {
|
||||
t.Fatalf("init 应带本地会话密钥作 Bearer, 实得 %q", h.gotAuth)
|
||||
}
|
||||
if u, _ := j["url"].(string); !strings.Contains(u, "bigmodel.cn") {
|
||||
t.Fatalf("应给出 authorize_url, 实得 %q", u)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 pending状态保持pending", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.readyAt, 99)
|
||||
_, sid := h.begin()
|
||||
for i := 0; i < 3; i++ {
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("status=pending 应保持 pending, 实得 %v", st["state"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 ready即完成_凭据含zcodeJwt与第三方token", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.readyAt, 2)
|
||||
_, sid := h.begin()
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("首拍应 pending: %v", st)
|
||||
}
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("ready 应 completed, 实得 %v", st)
|
||||
}
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:zcode")
|
||||
if !ok {
|
||||
t.Fatal("凭据未落库")
|
||||
}
|
||||
for _, want := range []string{"ZCODE-JWT-1", "BM-AT-1", "zcode-dev"} {
|
||||
if !strings.Contains(v, want) {
|
||||
t.Fatalf("凭据应含 %q, 实得 %s", want, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 池路径_凭据落账号ref", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.readyAt, 1)
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"zcode","account_id":%q}`, aid), Role: "admin",
|
||||
})
|
||||
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
|
||||
if st := h.pollOnce(sid); st["state"] != "completed" {
|
||||
t.Fatalf("池路径应完成: %v", st)
|
||||
}
|
||||
if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok {
|
||||
t.Fatalf("池路径凭据未落到 channels:zcode:%s", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
38
internal/core/codearts_sig_expected.json
Normal file
38
internal/core/codearts_sig_expected.json
Normal file
@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user