mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
113 Commits
v1.9.7
...
fix/channe
| Author | SHA1 | Date | |
|---|---|---|---|
| d3bb47bd54 | |||
| 4174310421 | |||
| fc5326402f | |||
| 56ea269f98 | |||
| aea1a2c48d | |||
| 9e5a5f550b | |||
| 8a4153d18a | |||
| 59902082c6 | |||
| dda537cda9 | |||
| 2d7388ad35 | |||
| 00e59a7e1d | |||
| c0e08f907a | |||
| 3ac4b35eb1 | |||
| 969f3138fd | |||
| 00db19eb77 | |||
| 38490690c5 | |||
| 969c72bfb1 | |||
| 423dd5cf22 | |||
| eeba1e49f8 | |||
| ade9c122eb | |||
| 71cc142615 | |||
| 33bb7a98b2 | |||
| 1e6b4864ec | |||
| 89d759ece7 | |||
| 8d23005f7d | |||
| ddbafe822c | |||
| 482a65958b | |||
| 796b30f5b0 | |||
| 25a525be84 | |||
| ea8a9ac4c6 | |||
| 8f73f77ec2 | |||
| c36a794aa8 | |||
| 0f263f895a | |||
| 044ff1129b | |||
| 9bec158d2e | |||
| 0004545190 | |||
| 1dda9e4477 | |||
| 04f12fae71 | |||
| cf53d72af4 | |||
| 03430bcef5 | |||
| 192e545d40 | |||
| de9d7c8008 | |||
| 8a3059740f | |||
| 028e42b036 | |||
| 84c9066bed | |||
| 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 |
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
72
README.md
72
README.md
@ -69,7 +69,9 @@ Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换
|
||||
(所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。
|
||||
- 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到
|
||||
常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。
|
||||
- **disable_thinking**:请求 `disable_thinking:true`(或上游对应字段)动态开关推理。
|
||||
- **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 前
|
||||
@ -150,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` 丢失后运行时密文无法解密。
|
||||
|
||||
@ -407,10 +450,13 @@ Environment=MALLOC_ARENA_MAX=2
|
||||
可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进,
|
||||
温和场景不必开。
|
||||
|
||||
### disable_thinking
|
||||
### thinking 预算
|
||||
|
||||
请求体带 `"disable_thinking": true` 时,网关透传给各适配器;DeepSeek 适配器将其
|
||||
映射为 `extra_body.thinking.type = "disabled"` 关闭推理,其余源按各自协议处理。
|
||||
请求体带 `"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
|
||||
|
||||
@ -458,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,
|
||||
}
|
||||
```
|
||||
|
||||
@ -466,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` 等头。
|
||||
@ -585,4 +639,4 @@ go test -tags luajit ./...
|
||||
```
|
||||
|
||||
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
|
||||
模型路由、多模态透传与 disable_thinking。
|
||||
模型路由、多模态透传与 thinking 预算。
|
||||
|
||||
@ -487,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`
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.7",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
158
deploy.sh
158
deploy.sh
@ -31,12 +31,20 @@ 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"
|
||||
@ -121,14 +129,14 @@ 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 "✓ 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
log "OK 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
else
|
||||
warn "线上配置不存在: $TARGET_CONFIG(首次部署?无配置回滚点)"
|
||||
fi
|
||||
@ -141,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" \
|
||||
@ -174,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
|
||||
}
|
||||
@ -206,7 +214,7 @@ atomic_replace_binary() {
|
||||
|
||||
# 验证
|
||||
NEW_SHA256=$(sha256sum "$TARGET_BIN" | awk '{print $1}')
|
||||
log "✓ 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
|
||||
log "OK 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
|
||||
}
|
||||
|
||||
# ---------- 原子替换配置 ----------
|
||||
@ -222,7 +230,7 @@ atomic_replace_config() {
|
||||
|
||||
log " rename → $TARGET_CONFIG (原子)"
|
||||
mv -f "$STAGING_CONFIG" "$TARGET_CONFIG"
|
||||
log "✓ 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
log "OK 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
}
|
||||
|
||||
# ---------- 配置预检 ----------
|
||||
@ -235,7 +243,7 @@ verify_config() {
|
||||
|
||||
local out
|
||||
if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then
|
||||
log "✓ 配置合法"
|
||||
log "OK 配置合法"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@ -275,7 +283,7 @@ verify_master_key() {
|
||||
log " 钥匙来源: LLMS_PROXY_MASTER_KEY(环境变量)"
|
||||
# 走真实解密路径验证,而不是只检查格式
|
||||
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
|
||||
log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
return 0
|
||||
fi
|
||||
err "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:"
|
||||
@ -291,7 +299,7 @@ verify_master_key() {
|
||||
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 "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
return 0
|
||||
fi
|
||||
err "凭据无法解密(钥匙与配置不匹配),未替换任何文件:"
|
||||
@ -301,48 +309,60 @@ verify_master_key() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 适配器备份保留策略 ----------
|
||||
# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次
|
||||
# ---------- 备份目录保留策略 ----------
|
||||
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
|
||||
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
|
||||
# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。
|
||||
#
|
||||
# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则
|
||||
# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。
|
||||
# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
|
||||
KEEP_ADAPTER_BACKUPS=5
|
||||
# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置
|
||||
# 就可能在几秒内造出成百上千个目录。
|
||||
# 只删名字严格匹配 <prefix>.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
|
||||
#
|
||||
# ★ 必须**同时覆盖 adapters 与 plugins**(同一缺陷的两个实例):
|
||||
# 本函数最初只处理 adapters.bak,而 sync_plugins 同样每次建 plugins.bak.*
|
||||
# —— 于是修了 adapters 的堆积、plugins 继续涨(实测线上 36 个、占用 192M)。
|
||||
# 这正是「两处只修一处」的老形状:给一个新前缀加备份目录时,必须同步纳入
|
||||
# prune_all_backups,否则它就是一个不会被清理的新泄漏点。
|
||||
KEEP_BACKUPS=5
|
||||
|
||||
prune_adapter_backups() {
|
||||
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 'adapters.bak.*' -printf '%f\n' | sort)
|
||||
local cap=$((KEEP_ADAPTER_BACKUPS * 4))
|
||||
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 "适配器备份目录有 ${#all[@]} 个(异常),裁到 $cap"
|
||||
warn "$prefix 备份目录有 ${#all[@]} 个(异常),裁到 $cap"
|
||||
local i=0
|
||||
for d in "${all[@]}"; do
|
||||
i=$((i + 1))
|
||||
# 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。
|
||||
if (( i <= ${#all[@]} - cap )); then
|
||||
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; 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 'adapters.bak.*' -printf '%f\n' | sort)
|
||||
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
|
||||
fi
|
||||
|
||||
# 再按时间保留最近 KEEP_ADAPTER_BACKUPS 份(sort 后最新在末尾)。
|
||||
# 再按时间保留最近 KEEP_BACKUPS 份(sort 后最新在末尾)。
|
||||
local total=${#all[@]} i=0
|
||||
for d in "${all[@]}"; do
|
||||
i=$((i + 1))
|
||||
# i <= total-KEEP 的是较旧的,要删。
|
||||
if (( i <= total - KEEP_ADAPTER_BACKUPS )); then
|
||||
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
|
||||
if (( i <= total - KEEP_BACKUPS )); then
|
||||
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
|
||||
rm -rf "${base:?}/$d"
|
||||
else
|
||||
warn "跳过名字不规范的备份目录(不删): $d"
|
||||
@ -351,8 +371,15 @@ prune_adapter_backups() {
|
||||
done
|
||||
|
||||
local left
|
||||
left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l)
|
||||
log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $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
|
||||
}
|
||||
|
||||
# ---------- 同步适配器 ----------
|
||||
@ -372,7 +399,7 @@ sync_adapters() {
|
||||
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
|
||||
log " 旧适配器已备份到 $BACKUP_DIR"
|
||||
fi
|
||||
prune_adapter_backups
|
||||
prune_all_backups
|
||||
|
||||
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
|
||||
chmod 0644 "$TARGET_ADAPTERS/"*.lua
|
||||
@ -387,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"
|
||||
}
|
||||
|
||||
# ---------- 健康检查 ----------
|
||||
@ -409,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)"
|
||||
@ -478,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
|
||||
@ -495,6 +583,7 @@ print_summary() {
|
||||
log " 二进制: $TARGET_BIN"
|
||||
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
|
||||
log " 适配器: $TARGET_ADAPTERS"
|
||||
log " 插件: $TARGET_PLUGINS"
|
||||
log "═════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
@ -511,6 +600,7 @@ main() {
|
||||
verify_master_key
|
||||
build
|
||||
sync_adapters
|
||||
sync_plugins
|
||||
backup_old_state
|
||||
atomic_replace_binary
|
||||
atomic_replace_config
|
||||
|
||||
333
docs/channel-repair-plan.md
Normal file
333
docs/channel-repair-plan.md
Normal file
@ -0,0 +1,333 @@
|
||||
# 渠道链路修复计划与现状
|
||||
|
||||
> 本文是 `plan.md` 中「渠道链路修复计划(2026-10-17 起)」章节的**可提交版本**。
|
||||
> `plan.md` 本身被 `.gitignore` 忽略(本地工作台账),故关键计划与现状单独落在这里,
|
||||
> 以便随代码一起进 git、供他人查阅。
|
||||
>
|
||||
> **写作原则:只写实测到的,标注"推断"的必须写明。** 每条缺陷都附可复现的观测。
|
||||
|
||||
> 记录 **2026-10-17 用户报障三连修**(模型不可用 / 额度异常 / 其他渠道同类)
|
||||
> 的现状与后续计划。上接 `plan.md` §9(v2 契约收尾)。
|
||||
|
||||
## 1. 现状快照(2026-10-17 14:00 实测,非推断)
|
||||
|
||||
### 1.1 生产实例
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 服务 | `active`,NRestarts=0 |
|
||||
| 二进制 sha256 | `56396f79bfc2433a`(与仓库逐字节一致) |
|
||||
| 插件 sha256 | `c15cb4bf5d5c0f21`(与仓库逐字节一致) |
|
||||
| 插件 | 4 个(billing/channels/quota/usage)**全部 loaded** |
|
||||
| 源 / 适配器 | 21 源 / 23 适配器 |
|
||||
| 账号池 | **仅 trae、buddy 各有 1 个有凭据账号**;其余 10 个平台**账号数 0** |
|
||||
|
||||
### 1.2 各渠道缺陷矩阵(本文件写入时的实测)
|
||||
|
||||
自装适配器(插件在加载时经 `__llmsproxy_adapters` 装入):
|
||||
|
||||
| 适配器 | `build_headers` | 静态 `adapter.headers` | 流式转换 | 鉴权是否可能错 |
|
||||
|---|---|---|---|---|
|
||||
| `codearts` | 有 | 无 | 复合 openai | 有(SDK-HMAC 自签,已实现) |
|
||||
| `channels-trae` | **有(本次新加)** | 无 | **SOLO 自定义(本次新写)** | 已修 |
|
||||
| `channels-gemini` | **★ 缺** | 有 | 自定义 | 静态头 + Bearer,**待确认** |
|
||||
| `channels-zcode` | **★ 缺** | 无 | 无(继承 anthropic 基座) | **★ 高度可疑** |
|
||||
| `channels-cline` | **★ 缺** | 无 | 套 openai | **★ 可疑**(要 `Bearer workos:<jwt>`) |
|
||||
| `channels-qoder` | **★ 缺** | 无 | 套 openai | **★ 可疑**(头由 WASM 生成) |
|
||||
|
||||
内置适配器里 `build_headers` 只有 3 个有(buddy / kimicode / opencodezen),
|
||||
其余 11 个(含 **anthropic 基座**)靠静态头 + 内核兜底 `Bearer`。
|
||||
|
||||
> **★ anthropic 基座本身没有 `build_headers`**,而 anthropic 协议实际要
|
||||
> `x-api-key` 而非 `Authorization: Bearer`。`channels-zcode` 继承它 ⇒
|
||||
> 一旦有账号就会立刻暴露,与 trae 修复前完全同型。
|
||||
|
||||
### 1.3 已验证可用的通道(生产实测,不是单测)
|
||||
|
||||
| 模型(源限定名) | 结果 |
|
||||
|---|---|
|
||||
| `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` | ✅ 80 帧,正文 27 + 推理 299 |
|
||||
| `channels-trae-b8dd6b3df30e:glm-5.2` | ✅ OK |
|
||||
| `channels-buddy-buddy-main:hy4-preview` | ✅ OK |
|
||||
|
||||
| 端点 | 结果 |
|
||||
|---|---|
|
||||
| `channels/credits/status` (trae) | ✅ `active:true, dailyCredit:100, todayCheckedIn:true` |
|
||||
| `channels/credits/balances` (trae) | ✅ `total:598.516 credits` |
|
||||
|
||||
### 1.4 本轮已修(代码已部署生产,**尚未提交 git**)
|
||||
|
||||
**① 网关解不开 `源名:模型名` 限定名**(`internal/provider/registry.go`)
|
||||
|
||||
- 根因:`ResolvePinned` 用 `strings.IndexAny(model, "-:/")` 找**第一个**分隔符。
|
||||
渠道源名形如 `channels-trae-b8dd6b3df30e`(**含 `-`**)⇒ 切成 `src="channels"`
|
||||
⇒ 永不匹配 ⇒ `Resolve()` 返回 nil ⇒ `model "..." is not configured`。
|
||||
- 为什么必须支持:本实例 **44 个模型被多源同时提供**(`glm-5.2` 由 6 个源提供),
|
||||
WebUI 下拉框用 `源名:模型名` 作 option value 是**有意设计**。
|
||||
- 修法:抽 `splitPinned()`,**按已知源名做最长前缀匹配**(源名是已知集合,
|
||||
唯一可靠办法),保留旧 `-:/` 兜底。`EffectiveModel` 同步修。
|
||||
- 判据:`internal/provider/registry_pinned_name_test.go`(4 用例,变异 1/1 咬住)。
|
||||
|
||||
**② trae 适配器三连错**(`internal/lua/plugins/channels.lua`,逐层剥开)
|
||||
|
||||
1. **没有 `build_headers`** ⇒ 内核兜底发 `Bearer`,而 trae 推理端点要
|
||||
`Cloud-IDE-JWT` + 设备身份头 ⇒ 401「not able to authenticate you」。
|
||||
→ 与「额度不可用」是**同一根因的两个出口**(额度端点当轮修了、推理端点漏了)。
|
||||
2. **header 常量值是错的**:`X-App-Id` 抄成 `6eefa6c9`(真实
|
||||
`6eefa01c-1036-4c7e-9ca5-d891f63bfcd8`)、`app_version_code` 用 `0.1.52`
|
||||
(真实是**日期式** `20260811`)⇒ 上游 `4001 binding: expr_path=app_version_code,
|
||||
cause=parameter type does not match binding data`。
|
||||
3. **流式转换完全不对**:TRAE 是 **SOLO 自定义 SSE**
|
||||
(`event:output` → `data:{"response":"###","reasoning_content":...}`)。
|
||||
套 `openai_stream_chunk` ⇒ 无 `choices` ⇒ `empty stream`;
|
||||
改成 OpenAI 外壳 `{choices:[{delta}]}` **仍不对** —— 内核
|
||||
`json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构**,外壳字段全丢。
|
||||
⇒ 必须输出扁平 `{content, reasoning_content, usage, done, finish_reason}`。
|
||||
- 判据:`channels_trae_chat_headers_test.go`(3)、`channels_trae_solo_sse_test.go`(2),
|
||||
变异 2/2 咬住。
|
||||
|
||||
**③ 余额恒为 0**(同文件 `balance.total_fn`)
|
||||
|
||||
- 根因:读 `p.credits_limit`(pack **顶层**)—— 该字段**根本不存在**。
|
||||
真实路径是 `pack.entitlement_base_info.quota.credits_limit`
|
||||
(参考 `trae-credits.ts:341`)+ `pack.usage.credits_amount`。
|
||||
- 实测上游:`免费`(无 credits_limit) / `每月登录赠送`(500) / `签到奖励`(100) = **600**。
|
||||
- 修法:读嵌套路径 + 保留顶层兼容 + `usage_summary.{total,consumed}_amount` 兜底。
|
||||
- 判据:`channels_trae_credits_hdr_test.go`(1)。
|
||||
- 修后余额 **600 → 实测 598.516**(真实扣减)。
|
||||
|
||||
**④ 附带**:`channels_trae_test.go` 夹具从**扁平** `{"Token":...}` 改为真实
|
||||
`{"Result":{"Token":...}}` —— 原来夹具形状与生产不一致,**判据必然虚假通过**
|
||||
(这是 ExchangeToken 缺陷藏了很久的原因)。
|
||||
|
||||
## 2. 后续计划
|
||||
|
||||
### P0 — 提交本轮修复(必须先做)
|
||||
|
||||
代码已部署生产但**未进 git**。必须:
|
||||
1. 在 `release/v2.0.x` 上提交(含 3 个源文件 + 6 个判据文件)
|
||||
2. 打 tag `v2.0.40`(**这是内核 Go 改动**:`registry.go` ⇒ 真核心版本)
|
||||
3. 回流 main(cherry-pick,不 merge)
|
||||
4. 复核 tag 落点 + release vs main 零差异 + 生产 sha256 一致
|
||||
|
||||
> 判据:`git tag --points-at <release HEAD>` 非空;`git diff release/v2.0.x main` 为空。
|
||||
|
||||
### P1 — 其余渠道的同类缺陷(**账号 0,验证不了运行**)
|
||||
|
||||
按"风险 × 可验证性"排序。**核心限制:这 4 个平台账号数均为 0,
|
||||
无法做生产实测** —— 只能靠静态对齐参考 + 装好适配器后看 `__adapter_install_probe`。
|
||||
|
||||
| 优先 | 项 | 缺陷 | 修法 | 可验证性 |
|
||||
|---|---|---|---|---|
|
||||
| P1-1 | `anthropic` 基座 | 基座**无 `build_headers`**,Anthropic 协议要 `x-api-key` 不是 Bearer | 给基座补 `build_headers`(`x-api-key` + `anthropic-version`) | ✅ 已修+已判据;运行需账号 |
|
||||
| P1-1b | `channels-zcode` | **★ 证据冲突**:zcode 的 Messages 端点虽然**说 Anthropic 协议**,鉴权却是 `Bearer <zcode_jwt>`(参考 `zcode-transport.ts:121-125`,全仓 `x-api-key` 命中 **0** 次)。基座补了 x-api-key 后它**继承就会变错** | zcode **显式覆盖** `build_headers` 发 Bearer,不用继承 | ✅ 已修+已判据;运行需账号 |
|
||||
| P1-2 | `channels-cline` | 参考要 `Bearer workos:<jwt>`,我们的 token 无 `workos:` 前缀 | 适配器 `build_headers` 补前缀(幂等,已带不重复) | ✅ 已修+已判据;运行需账号 |
|
||||
| P1-3 | `channels-gemini` | 有静态头 + Bearer ⇒ **本来就对**(参考 `gemini.ts:353-366` 也是 OAuth Bearer,且刻意不发 `x-goog-api-key`) | **不改**,只加判据把这个结论钉住 | ✅ 已确认+已判据 |
|
||||
| P1-4 | `channels-qoder` | 头由 **WASM 生成必须原样透传**,我们拿不到 `.wasm` | **仍阻塞**:二进制不在我们仓库,且本仓**没有 wasm 运行时入口**。结论与接入方案见下节与 `docs/qoder-wasm-integration.md` | 本机账号 0 ⇒ 运行时不可验证 |
|
||||
|
||||
> #### ★ P1-1 与 P1-1b 的证据冲突(本轮最重要的一条)
|
||||
>
|
||||
> 计划原文把 P1-1 写成「anthropic 基座没 `build_headers` ⇒ zcode 继承就 401」。
|
||||
> 逆向参考后**两半各对一半**:
|
||||
>
|
||||
> | 断言 | 结论 | 证据 |
|
||||
> |---|---|---|
|
||||
> | Anthropic 协议(真 · api.anthropic.com)要 `x-api-key` | ✅ 对 | 协议本身 |
|
||||
> | zcode 的 `/zcode-plan/anthropic/v1/messages` 也要 `x-api-key` | ❌ **错** | `zcode-transport.ts:121-125` 发 `Authorization: Bearer ${zcode_jwt}`;`zcode-upstream.ts:83-108` 的 `buildZcodeHeaders` 只有可选 Authorization;全仓 `grep -rn "x-api-key" src/` = **0** |
|
||||
>
|
||||
> zcode 是「**Anthropic 请求体形状 + Bearer 鉴权**」——协议与鉴权是两个独立维度,
|
||||
> 被这家上游拆开了。所以修复不能"把基座改成 x-api-key 就完事":
|
||||
> 那会把 zcode 从「对」改成「错」。正确形态是**基座发 x-api-key + zcode 显式覆盖成 Bearer**。
|
||||
>
|
||||
> 附带约束:`anthropic` 基座**同时服务生产里的其它源**(`/etc/llmsproxy/config.yaml`
|
||||
> 里 `justwoker` 就是 `adapter: anthropic`)。给基座补 `x-api-key` 后,内核
|
||||
> `buildHeaders` 的兜底 Bearer **必须让位**(否则同一请求带两套鉴权),
|
||||
> 这处改动在 `internal/provider/provider.go`(内核改动)。
|
||||
|
||||
> #### ★ P1-4 的结论:qoder 为什么卡住(事实陈述,**未修复**)
|
||||
>
|
||||
> 本段只记录查证结果,不代表任何修复已完成。
|
||||
>
|
||||
> **① 二进制客观存在,但不在我们仓库里。**
|
||||
>
|
||||
> | 项 | 值 |
|
||||
> |---|---|
|
||||
> | 绝对路径 | `/opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm` |
|
||||
> | 大小 | `298606` 字节(291.6 KiB) |
|
||||
> | sha256 | `6419471effa631519def7797d76d7ede38b9fcfa9a83c2148c8ef5d43355b43d` |
|
||||
> | 格式 | `WebAssembly (wasm) binary version 0x1 (MVP module)` |
|
||||
> | 归属 | 第三方插件 `dsh-our-free-model` 的 vendored 依赖(`NOTICE.md` 记明来自 MIT 许可的上游 channel-pack,**不随本仓分发**) |
|
||||
>
|
||||
> ⇒ 我们仓库 `git ls-files | grep -i wasm` = **0 条**。任何「我们直接调用它」的实现都是
|
||||
> 对**别人安装目录**的硬依赖,换机/升级插件即失效。这是 P1-4 的第一层阻塞。
|
||||
>
|
||||
> **② 它是 wasm-bindgen 产物,为 JS 宿主生成,不是 self-contained wasm。**
|
||||
>
|
||||
> 证据(解析二进制 section 得到,非推断):
|
||||
> - 只有 **1 个 import module**:`./qoder_auth_wasm_bg.js`,31 个 import **全部**来自它;
|
||||
> - import 名带 wasm-bindgen 的哈希与 `__wbindgen_*` ABI 前缀
|
||||
> (`__wbindgen_object_drop_ref` / `__wbindgen_object_clone_ref` /
|
||||
> `__wbindgen_cast_0000000000000001|2` / `__wbg_static_accessor_GLOBAL_THIS_*`);
|
||||
> - 导出含 `__wbindgen_add_to_stack_pointer` / `__wbindgen_export1..4`、`memory`。
|
||||
>
|
||||
> 结论:它**不是**「喂 WASI 就能跑」的模块,而是要求宿主**自己实现一套 JS 值语义**
|
||||
> (对象表 + 索引、`Uint8Array`、`Map`、`crypto.getRandomValues`、`Date.now`)。
|
||||
> wasm-bindgen 的标准做法是「随 wasm 一起生成 12 KB 的 JS glue」,而该目录里
|
||||
> **没有** `qoder_auth_wasm_bg.js` —— 上游改为手写了一份等价 glue
|
||||
> (`vendor/channel-pack/src/qoder-wasm.ts`,706 行,含 31 个 import 的逐个实现)。
|
||||
> 这是 P1-4 的第二层阻塞:**代价在宿主侧,不在 wasm 侧**。
|
||||
>
|
||||
> **③ 31 个 import 按用途分四类。**
|
||||
>
|
||||
> | 类 | 数量 | 代表 | 纯 Go 宿主能否实现 |
|
||||
> |---|---|---|---|
|
||||
> | A. bindgen 对象表 ABI | 5 | `__wbindgen_object_drop_ref` / `object_clone_ref` / `cast_..._1` / `cast_..._2` | 能(Go 侧自建 `[]any` 索引表) |
|
||||
> | B. 字节缓冲原语 | 6 | `__wbg_new_with_length` / `length` / `subarray` / `set` / `prototypesetcall` / `new_...`(Map) | 能(`[]byte` + `map[string]any`) |
|
||||
> | C. 随机源与时间 | 4 | `getRandomValues` **两个方向相反的变体** / `randomFillSync` / `now` | 能(`crypto/rand`、`time.Now`),**但两个变体必须区分**:一个写 wasm 内存,一个调 JS crypto 对象 |
|
||||
> | D. JS/Node 环境探针与调用 | 16 | `crypto` / `msCrypto` / `process` / `versions` / `node` / `require` / `call` / `globalThis` / `self` / `window` / `throw` / `Error` / `is_object|string|function|undefined` | 部分能:`self`/`window` 按 JS 惯例返 `0`;`process`/`node`/`require` 返回 `undefined` 即可 —— **前提是从未被调用** |
|
||||
>
|
||||
> **④ 34 个 export 按用途分四类。**
|
||||
>
|
||||
> | 类 | 数量 | 成员(去 bindgen plumbing) |
|
||||
> |---|---|---|
|
||||
> | 1. 鉴权字段生成 | 1 | `generate_runtime_auth_fields` |
|
||||
> | 2. 请求构造 / 加解密(**红线所在**) | 13 | `qodercontext_new` · `qodercontext_prepareInferRequest` · `qodercontext_prepareRequest` · `qodercontext_refreshAuthFields` · `qodercontext_get_external_providers_access` · `requestresult_url|body|headers|headerCount` · `decrypt_server_response` · `profile_encrypt` · 2 个 `__wbg_*_free` |
|
||||
> | 3. 本地存储 / 缓存 / 凭据加解密 | 8 | `credential_storage_encrypt|decrypt` · `model_cache_encrypt|decrypt` · `profileencryptor_new|encryptChunk|header` · `__wbg_profileencryptor_free` |
|
||||
> | 4. 静态常量 / HTTPDNS / 环境探测 | 6 | `get_httpdns_config|account_id|secret_key` · `build_httpdns_url` · `get_external_providers_feature_gate_fallback` · `get_profile_key_fingerprint` |
|
||||
> | plumbing | 6 | `memory` · `__wbindgen_export1..4` · `__wbindgen_add_to_stack_pointer` |
|
||||
>
|
||||
> 只有第 2 类与 qoder 的**推理鉴权**直接相关(`prepareInferRequest` 产出的
|
||||
> `Authorization` 是 `Bearer COSY.<载荷>.<签名>`,**必须原样透传**);
|
||||
> 第 1 类是它的前置(`encrypt_user_info` / `key`);第 3、4 类在推理路径上不需要。
|
||||
>
|
||||
> **⑤ 我们仓库当前没有 wasm 运行时入口 —— 这是 P1-4 卡住的直接原因。**
|
||||
>
|
||||
> | 查证项 | 命令 | 结果 |
|
||||
> |---|---|---|
|
||||
> | Go 侧 wasm 运行时依赖 | `grep -rniE 'wazero|wasmtime|wasmer|tetratelabs' --include=*.go --include=*.mod --include=*.sum .` | **0 命中** |
|
||||
> | `go.mod` 直接依赖 | `cat go.mod` | 仅 `aarzilli/golua` + `yaml.v3` |
|
||||
> | `go.sum` 中 wasm 相关 | `grep -ci wasm go.sum` | **0** |
|
||||
> | Lua 侧 wasm 能力 | `grep -rniE 'wasm' internal/lua/` | 仅 **注释**(`plugins/channels.lua:141` 明写「WASM 签名(Qoder) 这类无法在当前 Lua 环境完成」) |
|
||||
> | 插件 VM 外呼能力 | `internal/lua/plugin_http.go` | 只有受限 HTTP(host 白名单 + 禁重定向 + 4 MiB 上限),**无任意代码执行/无 wasm** |
|
||||
>
|
||||
> 即:现状是「**Go 内核无 wasm 依赖、Lua 插件无 wasm 能力**」,而 qoder 的
|
||||
> 加密端点头必须由该 wasm 生成。我们目前走的是**明文端点**
|
||||
> `api2-v2.qoder.sh` + `/model/v1/chat/completions`(见
|
||||
> `internal/core/channels_upstream_gaps_test.go` 的判据),因此**不需要** wasm ——
|
||||
> 代价是该端点只认一小撮通用模型名,拿不到目录里 `qmodel_*` 系列的完整能力。
|
||||
>
|
||||
> **⑥ 本机不可验证。** qoder 账号数 **0**(`plan.md` §1.1 快照),
|
||||
> 因此「签名头是否被上游接受」这一层**无法做运行时验证**。
|
||||
> 能验证的只有静态层:wasm 可实例化、能被驱动产出请求头(见方案文档的实测节),
|
||||
> **但「判据通过」≠「功能可用」**。
|
||||
>
|
||||
> 可执行的最小路径方案与实测记录另见 **`docs/qoder-wasm-integration.md`**。
|
||||
|
||||
### P2 — 内核对"限定名"的契约收口
|
||||
|
||||
已落地(详见 **`docs/source-name-contract.md`**):
|
||||
|
||||
| 位置 | 行为 |
|
||||
|---|---|
|
||||
| `config.ValidateSourceName` / `SourceNameWarning` | 纯函数校验器(单一定义) |
|
||||
| `core.normalizeSource`(`AddSource` 入口) | 新增/修改源**硬拒**(API 400)—— 新数据从源头合法 |
|
||||
| `config.ApplyDefaults`(启动) | 存量带 `:` 的源名**只告警不报错**(避免升级 brick 掉整个网关) |
|
||||
|
||||
字符集:**仅 `[A-Za-z0-9_-]`**,禁 `:` 与 `/`(限定名分隔符 ⇒ 切分歧义且**不报错**)。
|
||||
`-` 允许(渠道源名 `channels-<平台>-<账号id>` 含 `-`,分隔符在源名之后不歧义)。
|
||||
|
||||
解析入口 `internal/provider/splitPinned` **不做校验**(解析不该拒绝已有数据),
|
||||
只做最长前缀匹配 —— 校验责任全在写入口,保持解析路径纯函数无副作用。
|
||||
|
||||
### P3 — 判据防回归(防"夹具与生产不一致"再犯)
|
||||
|
||||
本轮最大的教训不是某个 bug,而是**夹具形状错 ⇒ 判据虚假通过**(trae 活了两轮)。
|
||||
|
||||
已落地,分两层(文件:`internal/lua/channels_fixture_shapes_test.go` 行为层 +
|
||||
`internal/lua/channels_fixture_shapes_general_test.go` 通用形状层):
|
||||
|
||||
**① 形状登记表 `shapeFamilies`** —— 每族给出四项,缺一不可:
|
||||
|
||||
| 字段 | 含义 | 为什么必须有 |
|
||||
|---|---|---|
|
||||
| `real` | 生产原文(引自实测抓包/参考注释) | 夹具的真值来源 |
|
||||
| `naive` | 错误的「方便测试版」 | 证明判据能区分真假,不是恒等式 |
|
||||
| `fact(raw)` | 对**原始 JSON 的结构事实** | 判定「这段 body 是不是生产形状」 |
|
||||
| `eval` / `assert` | 用**真实实现**处理并断言行为 | 防判据自算一份解包逻辑 |
|
||||
|
||||
断言链:`fact(real)` 为真 **且** `fact(naive)` 为假 **且** `assert(eval(real))` 通过。
|
||||
|
||||
> ⚠️ 刻意**不比较 `eval(real)` 与 `eval(naive)` 的输出形状**:trae 余额实现
|
||||
> **合法保留**了「顶层 credits_limit」兼容兜底路径(实测两种上都返回 600),
|
||||
> 拿输出比对会把合法兼容判成错误。真正能区分真假的是 `fact`
|
||||
> —— 它问的是「生产响应里额度在哪一层」。
|
||||
|
||||
已登记三族(基线,元判据强制不可删):
|
||||
- **Result 信封** —— 凭据在 `Result` 里,顶层只有 `ResponseMetadata`
|
||||
- **SOLO 自定义 SSE** —— 正文在顶层 `response`,**无** `choices`
|
||||
- **嵌套 quota** —— `pack.entitlement_base_info.quota.credits_limit`(顶层**没**该字段)
|
||||
|
||||
**② 仓库扫描 `TestNoDegenerateFixturesInRepo`** —— 扫 `internal/**/*_test.go`,
|
||||
对**特征明确**的退化形状做静态反查(当前只留「额度挪到 pack 顶层」一条规则)。
|
||||
需要退化夹具来测兼容路径时,必须在文件里写 `// SHAPE-OK: <理由>` 显式豁免。
|
||||
|
||||
> 为什么扫描只留一条规则:文本匹配对另两族**必然误报** —— SOLO 判据自己会写
|
||||
> `Fatalf("不得返回 choices…")`,天然含 choices。那两族的精确钉法
|
||||
> 是 ① 的 `fact` 判据,不是文本匹配。
|
||||
|
||||
#### 本轮修复的判据清单(P1/P2/P3)
|
||||
|
||||
| 判据文件 | 用例数 | 钉住什么 |
|
||||
|---|---|---|
|
||||
| `internal/lua/adapter_anthropic_headers_test.go` | 3 | 基座发 `x-api-key`+`anthropic-version`(恰好 2 键)、不发 Authorization、无凭据不发空头、读 `source.meta.api_key` 兜底 |
|
||||
| `internal/lua/adapter_zcode_build_headers_test.go` | 3 | zcode **覆盖**成 `Bearer`、无 `x-api-key`;基座 vs zcode 鉴权**真的不同**;无凭据不发空头 |
|
||||
| `internal/lua/channels_cline_workos_test.go` | 3 | 无前缀补上 / 已有不重复 / 大小写变体不重复;无 token 不发 `Bearer workos:` |
|
||||
| `internal/lua/channels_gemini_auth_check_test.go` | 2 | **P1-3 结论**:gemini 不自带鉴权(身份五头 + 兜底 Bearer 即正确),不得发 Authorization/x-api-key/x-goog-api-key |
|
||||
| `internal/provider/build_headers_fallback_test.go` | 2 | 内核兜底让位给 x-api-key;其它适配器 Bearer 行为**不变** |
|
||||
| `internal/config/source_name_contract_test.go` | 4 | 允许字符集、`:`/`/` 点名拒绝、其它非法字符、存量只告警 |
|
||||
| `internal/core/source_name_contract_test.go` | 2 | **真实写入口** `AddSource` 硬拒且拒绝发生在写盘之前;合法渠道名照常接受 |
|
||||
| `internal/lua/channels_fixture_shapes_test.go` | 4 | 三族行为层:Result 信封 / SOLO SSE / 嵌套 quota + 元判据 |
|
||||
| `internal/lua/channels_fixture_shapes_general_test.go` | 3 | 通用形状层:`fact(real)&&!fact(naive)&&assert(eval(real))`;仓库扫描;登记表自检 |
|
||||
| `internal/core/adapter_install_probe_test.go` | 1 | `__adapter_install_probe` 报告 6/6 自装适配器全绿(`luac -p` 对长字符串无效) |
|
||||
| `internal/lua/adapter_zcode_harness_test.go` | —(辅助) | 兄弟目录测试台:自带 zcode 装载断言,装不上就响亮失败 |
|
||||
|
||||
### P4 — 待用户决策
|
||||
|
||||
| 项 | 需要什么 |
|
||||
|---|---|
|
||||
| zcode 真实登录 | 用户点授权(身份块 + init-poll 流已就绪) |
|
||||
| cline / qoder / gemini / codearts / minimax / lobsterai / opencode | 需各平台账号;`device-poll` 系列要用户去官方页输码 |
|
||||
| raccoon / loomy | 需扫码 |
|
||||
| qoder WASM | 用户提供 `.wasm` |
|
||||
|
||||
## 3. 风险与纪律(本轮新增)
|
||||
|
||||
1. **`luac -p` 对长字符串内的适配器代码无效**(只看外层)⇒ 会得到假绿。
|
||||
**必须**用 `__adapter_install_probe` 看真实装载结果。本轮我因此把 TRAE 的
|
||||
SSE 代码误塞进 CODEARTS 适配器,导致 codearts 装载失败而 `luac` 一直通过。
|
||||
2. **Python 字符串替换会静默不生效**:锚文本缩进不符 ⇒ 匹配 0 次。
|
||||
规矩:① 先 `cp` 备份 ② **行号定位** ③ 写后 `luac -p` + `grep -c` 双确认
|
||||
④ 长字符串内的改动必须验装载。
|
||||
3. **`function` 是 Lua 关键字**:`cc.function` 是语法错误,必须 `cc["function"]`;
|
||||
但批量替换时别把 `cc.function_call` 一起改坏(本轮踩过,导致
|
||||
`')' expected near '_call'`)。
|
||||
4. **判据要咬被测对象**:本轮 `channels_trae_credits_hdr_test.go` 第一版造 HTTP 假上游,
|
||||
因 URL 覆盖/凭据 ref/路径匹配任一环错,"没发请求"伪装成"头不对"(耗了三轮)。
|
||||
改成直接咬 `spec.headers_fn` 的真实产出,一轮就准。
|
||||
5. **只改插件不必换二进制**;改内核 Go 必须换。混着发会把"插件改动"记成"核心版本"
|
||||
(v2.0.21–v2.0.39 有 18 个 tag 是这么来的,见 §4)。
|
||||
6. **`/tmp` 是 8.8G tmpfs**,Go 链接失败常见于它满(`no space left on device`)。
|
||||
元凶通常是 `/tmp/gocache`(可安全清)。
|
||||
|
||||
## 4. 账务实况(必须记住)
|
||||
|
||||
| 事实 | 值 |
|
||||
|---|---|
|
||||
| 内核 Go 代码自 v2.0.20 起的**全部**改动 | 仅 `internal/lua/adapter_prelude.go`、`internal/provider/provider.go`、`internal/provider/registry.go` |
|
||||
| 只改插件却发了核心 tag 的版本 | v2.0.21–v2.0.39 中的 **18 个** |
|
||||
| 当前最新 tag | `v2.0.39`(`091f790`),落点正确 |
|
||||
| main | `56ea269`(与 release 内容零差异) |
|
||||
| **未提交的改动** | 本轮三个修复(见 §1.4),已部署生产 |
|
||||
|
||||
> ⇒ **版本号语义教训**:以后"改插件不打核心 tag",插件自身维护版本;
|
||||
> 内核版本只在 Go 代码变更时递增。
|
||||
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 生效、危险动作需显式授权
|
||||
@ -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`)。
|
||||
|
||||
190
docs/plugins.md
190
docs/plugins.md
@ -46,7 +46,7 @@ plugin.ui = {
|
||||
page = {
|
||||
page_id = "hello", -- kebab-case
|
||||
title = "Hello",
|
||||
icon = "👋",
|
||||
icon = "",
|
||||
order = 90, -- 侧栏排序
|
||||
mount = [[<div id="hello">hello</div>]],
|
||||
},
|
||||
@ -211,7 +211,7 @@ plugin.ui = {
|
||||
page = {
|
||||
page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
|
||||
title = "Billing", -- 必填,侧栏文字
|
||||
icon = "💰", -- 可选
|
||||
icon = "", -- 可选
|
||||
order = 40, -- 侧栏排序,默认 100
|
||||
mount = [[...HTML...]],
|
||||
},
|
||||
@ -301,20 +301,184 @@ PUT /api/plugins/{name}/state 替换状态(admin)
|
||||
`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 状态
|
||||
### 6.1 一个插件 = 一个 Lua 状态(也就是一个 VM)
|
||||
|
||||
适配器可以有多个独立的 worker 状态(它们无状态,这是对的)。**插件不行**:
|
||||
钩子通常往 `plugin.state` 里累加,多个状态就意味着总额被劈成几份,而写价格只
|
||||
写进其中一个状态,钩子恰好跑到另一个时**所有请求按 0 计费**。
|
||||
|
||||
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState`)用互斥锁
|
||||
串行化。代价:**一个卡住的钩子会卡住所有插件的钩子**。所以——
|
||||
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**)
|
||||
都用这同一把互斥锁串行化。
|
||||
|
||||
> **钩子必须短、同步、不阻塞。** 不要在里面做 HTTP 请求、睡眠或重计算。
|
||||
注意: 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
|
||||
`plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了,
|
||||
对实际流量完全无效。
|
||||
|
||||
代价:**一个卡住的钩子会卡住该插件自己的钩子**(跨插件仍然并行,每插件各自
|
||||
一把锁、各自一个 VM)。所以——
|
||||
|
||||
> **钩子与定时任务都必须短、同步、不阻塞。** 不要在钩子里做 HTTP 请求、睡眠
|
||||
> 或重计算;长耗时的周期工作应放进 `PLUGIN.schedule`(见 §5.4),它在自己的
|
||||
> 调度 goroutine 上跑,但仍然持有这把锁,因此不能是死循环。
|
||||
|
||||
### 6.2 异常被隔离
|
||||
|
||||
@ -326,7 +490,7 @@ PUT /api/plugins/{name}/state 替换状态(admin)
|
||||
与适配器相同:`json.encode` / `json.decode`、`log(level, msg)`、
|
||||
`hmac_sha256_hex` / `sha256_hex` / `base64_encode` / `tohex`。
|
||||
|
||||
⚠️ `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
|
||||
注意: `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
|
||||
|
||||
### 6.4 命名
|
||||
|
||||
@ -413,7 +577,7 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
|
||||
{ "prices": { ... }, "count_failures": true }
|
||||
```
|
||||
|
||||
### 7.5 ⚠️ 计费插件只报表,不执法
|
||||
### 7.5 注意: 计费插件只报表,不执法
|
||||
|
||||
**网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。**
|
||||
本插件不参与任何路由或配额决策。
|
||||
@ -497,10 +661,12 @@ journalctl -u llmsproxy -f | grep -i plugin
|
||||
| | 适配器 | 插件 |
|
||||
|---|---|---|
|
||||
| 位置 | `adapter_dir` | `plugin_dir` |
|
||||
| 作用 | 上游协议转换 | 请求流水线 + 界面 |
|
||||
| 钩子 | `transform_request` / `transform_response` / `transform_stream_chunk` / `build_headers` / `transform_error` | `request_start` / `routed` / `request_end` |
|
||||
| 状态 | 每个源独立,多 worker | 单状态,见 §6.1 |
|
||||
| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失 |
|
||||
| 作用 | 上游协议转换 | 请求流水线 + 界面 + 周期任务 |
|
||||
| 钩子 | `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 |
|
||||
| 必需性 | 核心 | 可选,缺了网关照常跑 |
|
||||
|
||||
|
||||
404
docs/qoder-wasm-integration.md
Normal file
404
docs/qoder-wasm-integration.md
Normal file
@ -0,0 +1,404 @@
|
||||
# Qoder WASM 接入评估(`qoder-auth-wasm.wasm`)
|
||||
|
||||
> **状态:未实现(not implemented)。**
|
||||
> 本文给出**已实测的可行性结论**与**可执行的接入方案**,但我们仓库里
|
||||
> **没有任何 wasm 运行时、也没有任何代码加载这个 wasm**。
|
||||
> 本文写于 `fix/channel-auth-headers` 分支,仅文档 + 评测工具,不动内核。
|
||||
|
||||
> **判据 ≠ 功能可用。** 本文所有「PASS」都是**本机静态/离线**结论:
|
||||
> qoder 账号数 **0**,所以「签名头是否被 Qoder 上游接受」**没有做过运行时验证**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
| 问题 | 结论 | 依据 |
|
||||
|---|---|---|
|
||||
| wasm 能不能被加载? | **能**,且**不需要 JS 引擎** —— 纯 Go(wazero)即可实例化 | 31 个 import 全部由 Go 侧桩提供,实例化阶段真实调用 **0** 次 |
|
||||
| 能不能真正跑出鉴权头? | **能**,且**已实测做到** | `prepareInferRequest` 产出 20 个头,含 `Authorization: Bearer COSY.<载荷>.<签名>`,body 为 60 字节加密串 |
|
||||
| 31 个 import 要全部实现吗? | **不用。实际路径只触碰 18 个**,其中 5 个是 node/JS 环境探针(`process`/`node`/`require`/`versions`/`call`),**本路径一次都没调用** | 逐 import 计数(§4 表) |
|
||||
| 最小可行接入是什么? | **新增一个 `build_headers` 后端(wazero)**,由适配器把 `meta` 交给它,拿回 headers 原样透传 | §5 |
|
||||
| 本轮能做进 `fix/` 分支吗? | **不能**。见 §7 改动面与风险 —— 这属于主分支级改动 | §7 |
|
||||
| 风险最高的是什么? | **不是** wasm 调用,而是**二进制来源**(不在我们仓库,也不可再分发)与**账号数为 0 无法端到端验证** | §6 |
|
||||
| 性能可以接受吗? | **可以**。实测 ≈ **2.2 ms/请求**,线性内存 ≈ **1.25 MiB**,实例可复用 | §8 |
|
||||
|
||||
> ⚠️ **一条红线**:qoder 加密端点要求 `Authorization` **原样透传**。
|
||||
> 任何「适配器里再兜底一次 `Bearer <api_key>`」的写法都会覆盖签名,得到
|
||||
> `403 Signature invalid`。这必须在接入时由代码结构保证,不能只靠注释
|
||||
> (见 §5.4)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标物:abs 路径 / 大小 / sha256
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 绝对路径 | `/opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm` |
|
||||
| 大小 | `298606` 字节(291.6 KiB) |
|
||||
| sha256 | `6419471effa631519def7797d76d7ede38b9fcfa9a83c2148c8ef5d43355b43d` |
|
||||
| 格式 | `WebAssembly (wasm) binary version 0x1 (MVP module)` |
|
||||
| 归属 | 第三方插件 `dsh-our-free-model` 的 vendored 依赖;`NOTICE.md` 注明来自 MIT 许可的上游 channel-pack,**不随本仓分发** |
|
||||
| 本仓引用 | `git ls-files \| grep -i wasm` = **0 条** |
|
||||
|
||||
同一目录下的相关文件(**都不在我们仓库里**):
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `pack.js`(2.8 MB) | 插件运行时实际加载的单文件 bundle |
|
||||
| `src/qoder-wasm.ts`(706 行) | **手写的 wasm-bindgen glue** —— 31 个 import 的逐个实现 + ABI 助手。本文的 Go 实现即照它翻译 |
|
||||
| `NOTICE.md` / `LICENSE` | 出处与许可(MIT) |
|
||||
|
||||
> `qoder_auth_wasm_bg.js`(wasm-bindgen 本该生成的那份 glue)在该目录中
|
||||
> **不存在** —— 上游改用手写的 `qoder-wasm.ts` 替代。这一点决定了接入代价
|
||||
> 落在**宿主侧**,而这正好是可以由 Go 承担的部分。
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么它是「wasm-bindgen 产物,给 JS 用」
|
||||
|
||||
解析二进制各 section(不是推断):
|
||||
|
||||
- **只有 1 个 import module**:`./qoder_auth_wasm_bg.js`,31 个 import **全部**来自它;
|
||||
- import 名同时带有 wasm-bindgen 哈希后缀(`__wbg_set_08463b1df38a7e29`)与
|
||||
`__wbindgen_*` ABI 前缀;
|
||||
- 导出含 ABI plumbing:`memory`、`__wbindgen_add_to_stack_pointer`、`__wbindgen_export1..4`;
|
||||
- 返回多值靠**写栈槽**传递(`ptr/len/valIdx/isErr`),这是 wasm-bindgen 的约定。
|
||||
|
||||
⇒ 它**不是**「喂 WASI 就能跑」的模块。它要求宿主实现 `JS 值语义`
|
||||
(对象表 + 索引、`Uint8Array`、`Map`、`crypto.getRandomValues`、`Date.now`)。
|
||||
`--export-all` 之类参数改不了这一点:ABI 就写在 import 表里。
|
||||
|
||||
---
|
||||
|
||||
## 4. 31 import / 34 export 的四类用途(附实测触碰次数)
|
||||
|
||||
### 4.1 imports(31 个,全部来自 `./qoder_auth_wasm_bg.js`)
|
||||
|
||||
| 类 | # | 成员 | 纯 Go 可否 | **实测触碰** |
|
||||
|---|---|---|---|---|
|
||||
| **A. bindgen 对象表 ABI** | 4 | `__wbindgen_object_drop_ref`、`object_clone_ref`、`cast_0000000000000001`、`cast_0000000000000002` | ✅ 自建 `[]any` 索引表 | 3(`cast_..2`×40、`clone`×2、`drop`×65) |
|
||||
| **B. 字节缓冲 / Map** | 6 | `new_with_length`、`length`、`subarray`、`set`、`prototypesetcall`、`new_99cabae`(Map) | ✅ `[]byte` + `map` | 6(全部触碰) |
|
||||
| **C. 随机源与时间** | 4 | `getRandomValues` **两个方向相反的变体**、`randomFillSync`、`now` | ✅ `crypto/rand`、`time.Now` | 3(`randomFillSync` 未被调用) |
|
||||
| **D. JS/Node 环境探针** | 17 | `crypto`、`msCrypto`、`process`、`versions`、`node`、`require`、`call`、`globalThis`、`self`、`window`、`throw`、`Error`、`is_object`、`is_string`、`is_function`、`is_undefined` | 部分:`self`/`window` 返 `0`;`process`/`node` 等**从不被调用**即可 | 4(`crypto`×1、`globalThis`×1、`self`×1、`window`×1、`is_object`×1、`is_undefined`×1) |
|
||||
|
||||
**实测合计:31 个中 13 个「从未被调用」。** 未触达的 13 个:
|
||||
|
||||
```
|
||||
__wbg_process_44c7a14e11e9f69e __wbg_versions_276b2795b1c6a219
|
||||
__wbg_node_84ea875411254db1 __wbg_require_b4edbdcf3e2a1ef0
|
||||
__wbg_msCrypto_bd5a034af96bcba6 __wbg_randomFillSync_6c25eac9869eb53c
|
||||
__wbg_call_d578befcc3145dee __wbg_static_accessor_GLOBAL_f2e0f995a21329ff
|
||||
__wbg___wbindgen_throw_81fc77679af83bc6 __wbg_Error_2e59b1b37a9a34c3
|
||||
__wbg___wbindgen_is_string_b29b5c5a8065ba1a
|
||||
__wbg___wbindgen_is_function_49868bde5eb1e745
|
||||
__wbindgen_cast_0000000000000001
|
||||
```
|
||||
|
||||
> 这是**本路径**的观测(`generate_runtime_auth_fields` + `qodercontext_new`
|
||||
> + `prepareInferRequest`)。错误分支会用到 `throw`/`Error`,所以实现里
|
||||
> 仍应给它们一个真实语义,而不是留空。
|
||||
|
||||
### 4.2 exports(34 个)
|
||||
|
||||
| 类 | # | 成员 | 与本接入的关系 |
|
||||
|---|---|---|---|
|
||||
| **1. 鉴权字段生成** | 1 | `generate_runtime_auth_fields` | **必需**(`encrypt_user_info` / `key` 是 `qodercontext_new` 的前置) |
|
||||
| **2. 请求构造 / 加解密** | 13 | `qodercontext_new`、`qodercontext_prepareInferRequest`、`qodercontext_prepareRequest`、`qodercontext_refreshAuthFields`、`qodercontext_get_external_providers_access`、`requestresult_url\|body\|headers\|headerCount`、`decrypt_server_response`、`profile_encrypt`、`__wbg_qodercontext_free`、`__wbg_requestresult_free` | **核心**:本接入只需其中 6 个 |
|
||||
| **3. 本地存储 / 缓存 / 凭据加解密** | 8 | `credential_storage_encrypt\|decrypt`、`model_cache_encrypt\|decrypt`、`profileencryptor_new\|encryptChunk\|header`、`__wbg_profileencryptor_free` | **不需要**(推理路径不涉及;仅「离线读本机目录缓存」会用到) |
|
||||
| **4. 静态常量 / HTTPDNS / 环境探测** | 6 | `get_httpdns_config\|account_id\|secret_key`、`build_httpdns_url`、`get_external_providers_feature_gate_fallback`、`get_profile_key_fingerprint` | **不需要**(可换成我们自己的常量) |
|
||||
| plumbing | 6 | `memory`、`__wbindgen_export1..4`、`__wbindgen_add_to_stack_pointer` | 必需 |
|
||||
|
||||
> 即:**34 个 export 里,接入只需要 1 + 6 + plumbing**。
|
||||
> 其余 27 个不影响「能不能发出一条被签名的 qoder 请求」。
|
||||
|
||||
---
|
||||
|
||||
## 5. 最小可行接入方案(设计,未实现)
|
||||
|
||||
### 5.1 依赖选型
|
||||
|
||||
| 候选 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| **`github.com/tetratelabs/wazero` v1.9.0** | ✅ **推荐** | 纯 Go、`CGO_ENABLED=0` 可交叉编译、**零传递依赖**(`go list -m all` 只有它自己);`go 1.22` 下限,本仓 `go 1.25` 无冲突 |
|
||||
| `wasmtime-go` | ❌ | 需要 CGO + 预编译动态库 ⇒ 破坏 `go build` 的单二进制分发与 Windows 交叉编译 |
|
||||
| `wasmer-go` | ❌ | 同上,且维护活跃度低 |
|
||||
| 自己写解释器 | ❌ | 不值当 |
|
||||
| 用 Node 子进程跑上游 `pack.js` | ⚠️ 备选 | 现有 node 可跑,但把「一个 Go 单二进制网关」变成「依赖宿主 node + 一份 2.8 MB 外部 bundle」,运维形态倒退 |
|
||||
|
||||
体积实测(`CGO_ENABLED=0`,空 `main`):
|
||||
|
||||
| 构建 | 大小 |
|
||||
|---|---|
|
||||
| 空程序 | 1,895,775 B |
|
||||
| `+ wazero`(仅引用 `NewRuntime`) | 3,106,358 B |
|
||||
| 增量 | **≈ +1.16 MiB** |
|
||||
|
||||
> 交叉编译已验证:`GOOS=windows GOARCH=amd64 CGO_ENABLED=0` 出 3,050,496 B,
|
||||
> 与现有 `gui-win` 流程兼容。
|
||||
|
||||
### 5.2 二进制从哪来(**必须先解决**)
|
||||
|
||||
wasm **不入库**会带来三个问题:换机失效、插件升级后 sha256 漂移、离线构建不安全。
|
||||
三条可选路径,推荐 ①:
|
||||
|
||||
| 方案 | 做法 | 代价 |
|
||||
|---|---|---|
|
||||
| **① 运维侧提供,启动时校验**(推荐) | 新增配置项 `qoder_wasm_path`;启动/首次使用时读文件,**校验 sha256 必须等于** `6419471e…55b43d`,不符则拒绝启用并报错 | 需要运维放一份文件;但**不引入许可问题**,且校验把「用错版本」变成显式失败 |
|
||||
| ② 我们仓库 `go:embed` | 把 wasm 放进 `internal/qoderwasm/assets/` 并 embed | ✅ 单二进制、无外部依赖;❌ **许可与再分发**需先确认(上游 MIT,但需保留 NOTICE),且 291 KiB 进 git |
|
||||
| ③ 运行时从上游插件目录读(我们现在的路径) | 直接读 `/opt/dsh-plugins/...` | ❌ 对别人安装目录硬依赖,**不建议进主干** |
|
||||
|
||||
> **本轮不选任何一条** —— 选型本身就是需要主管决策的事项(涉及再分发与
|
||||
> 运维约定),不该由一条 fix 分支暗定。
|
||||
|
||||
### 5.3 接入点:放在哪
|
||||
|
||||
现架构事实(`grep` 实测):
|
||||
|
||||
- 适配器是 **Lua**(`internal/lua/adapters/*.lua` + 插件自装 `channels-qoder`);
|
||||
- `channels-qoder` 目前**没有 `build_headers`**,靠静态头 + 内核兜底 `Bearer`;
|
||||
- 内核统一入口是 `Provider.buildHeaders`(`internal/provider/provider.go:1104`)
|
||||
→ `VM.BuildHeaders`(`internal/lua/vm.go:1077`)→ Lua `adapter.build_headers(meta)`;
|
||||
- **`go.mod` 里没有任何 wasm 运行时**;`internal/` 下 wasm 相关命中**全部是注释**;
|
||||
- Lua 侧**没有** wasm 能力(`channels.lua:141` 自己写明「无法在当前 Lua 环境完成」)。
|
||||
|
||||
⇒ 既然桥接面是 `build_headers(meta) → map[string]string`,**最干净的接法是在
|
||||
这个桥后面加一个 Go 侧后端**,而不是把 wasm 塞进 Lua:
|
||||
|
||||
```
|
||||
Provider.buildHeaders (internal/provider/provider.go)
|
||||
└─ VM.BuildHeaders (internal/lua/vm.go)
|
||||
└─ Lua adapter.build_headers(meta) ← channels-qoder 适配器
|
||||
└─ __llmsproxy_kernel("qoder.wasm_headers", meta) ← 新增内核动作
|
||||
└─ internal/qoderwasm.PrepareHeaders(meta) ← 新增包(wazero)
|
||||
└─ qoder-auth-wasm.wasm
|
||||
```
|
||||
|
||||
**为什么走 `__llmsproxy_kernel` 而不是新增一个 Lua 全局**:
|
||||
`internal/lua/plugin_bridges.go:146` 已有 `registerKernelBridge`,它是
|
||||
**按插件声明授权**的(插件在 `plugin.kernel_actions` 里列白名单)。
|
||||
新增一个 Lua 全局等于给**所有**插件开能力;走 kernel action 则保持「谁声明
|
||||
谁获得」。这与现有 `plugin_http.go` 的收窄思路一致。
|
||||
|
||||
**为什么不在 provider 里直接 if-else 厂商名**:本仓的既有约束是
|
||||
「内核零厂商名」(见 `channels.lua:1017` 的注释与 P2 段)。wasm 调用应作为
|
||||
一个**通用内核能力**暴露(`wasm.headers`),由插件把 qoder 语义喂给它。
|
||||
|
||||
### 5.4 输入 / 输出边界
|
||||
|
||||
```go
|
||||
// internal/qoderwasm(新包,示例形参;未实现)
|
||||
type Credentials struct {
|
||||
UID string `json:"uid"`
|
||||
SecurityOauthToken string `json:"security_oauth_token"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
OrganizationTags []string `json:"organization_tags"`
|
||||
DataPolicyAgreed bool `json:"data_policy_agreed"`
|
||||
MachineID string `json:"machine_id"` // 必填:缺失会 AES-GCM 失败
|
||||
ClientVersion string `json:"client_version"` // 默认 "1.1.49"
|
||||
}
|
||||
|
||||
type Metadata struct { // 写入 QoderContext
|
||||
ClientType string `json:"client_type"`
|
||||
BusinessProduct string `json:"business_product"`
|
||||
BusinessType string `json:"business_type"`
|
||||
Scene string `json:"scene"`
|
||||
}
|
||||
|
||||
type InferRequest struct {
|
||||
Host string // ⚠️ 必须 api2.qoder.sh 系,不能是 api2-v2
|
||||
Body json.RawMessage // 明文 payload(由插件构造)
|
||||
ModelKey string // 目录 key,如 qfmodel
|
||||
Source string
|
||||
}
|
||||
|
||||
// PrepareHeaders 返回**必须原样透传**的头集合。
|
||||
func PrepareHeaders(ctx context.Context, c Credentials, m Metadata, r InferRequest) (map[string]string, error)
|
||||
```
|
||||
|
||||
**输出实测形态**(20 个头):
|
||||
|
||||
```
|
||||
Accept text/event-stream Authorization Bearer COSY.<载荷>.<签名>(len=209)
|
||||
Cache-Control no-cache Connection keep-alive
|
||||
Content-Type application/json Cosy-Business-Product qoder
|
||||
Cosy-Business-Type default Cosy-ClientType 10
|
||||
Cosy-Data-Policy disagree Cosy-Date 1792279699
|
||||
Cosy-Key <modelKey> Cosy-MachineId <machineId>
|
||||
Cosy-MachineToken <machineId> Cosy-MachineType 5
|
||||
Cosy-Scene chat Cosy-User <uid>
|
||||
Cosy-Version 1.1.49 Login-Version v2
|
||||
X-Model-Key <modelKey> X-Model-Source system
|
||||
```
|
||||
|
||||
> 其中只有 `Authorization` / `Cosy-Key` / `Cosy-MachineId` / `Cosy-User` 等是
|
||||
> **动态**的;其余来自 `Metadata`/常量。注意 `Cosy-Date` 并非当前时间
|
||||
> (实测 `1792279699`,是 WASM 内部计算),**不要用 Go 时间覆盖**。
|
||||
|
||||
**红线(结构保证,不是注释)**:`Provider.buildHeaders`
|
||||
(`provider.go:1141`)当前逻辑是
|
||||
`if h.Get("Authorization")=="" && h.Get("X-Api-Key")=="" { 补 Bearer }`。
|
||||
qoder 拿到签名 `Authorization` 后自然跳过兜底 —— 这个**已有**行为正好安全,
|
||||
但新接入**必须有一个判据钉住**:签名头存在时兜底 Bearer 不得生效。
|
||||
|
||||
### 5.5 31 个 import 的 Go 实现映射表
|
||||
|
||||
| import | Go 实现 | 备注 |
|
||||
|---|---|---|
|
||||
| `__wbindgen_object_drop_ref` | **no-op(不释放槽位)** | ⚠️ 实测该模块会 `drop` 一个 Map 之后**继续用同一索引** `set`。wasm-bindgen 新版把 drop 退化为空操作(表交给 JS GC)。按「释放槽位」实现会得到 `set on undefined` |
|
||||
| `__wbindgen_object_clone_ref` | `push(obj[idx])` | |
|
||||
| `__wbindgen_cast_..._1` | wasm slice → `[]byte` | 本路径未触碰 |
|
||||
| `__wbindgen_cast_..._2` | wasm str → Go `string` | 最高频(40 次):字符串返回靠它 |
|
||||
| `__wbg_new_with_length` | `make([]byte, n)` | |
|
||||
| `__wbg_length` | `len(buf)` | |
|
||||
| `__wbg_subarray` | `buf[lo:hi]`(**共享底层数组**,不要 copy) | |
|
||||
| `__wbg_set` | 两种形态:`[]byte.set(src, off)` / `Map.set(k,v)` | 全靠 `arg0` 的动态类型分派 |
|
||||
| `__wbg_prototypesetcall` | 直接写 wasm 内存 | |
|
||||
| `__wbg_new_99cabae` | `new Map()` → 有序 `{keys,vals}` | `requestresult_headers` 的容器 |
|
||||
| **`__wbg_getRandomValues_d49329ff89a07af1`** | `crypto/rand` 写 **wasm 内存** `(ptr,len)` | ⚠️ 与下面那个**签名方向相反** |
|
||||
| **`__wbg_getRandomValues_c44a50d8cfdaebeb`** | 对 **crypto 对象** `(objIdx, bufIdx)` 填随机 | ⚠️ 写反 ⇒ Rust `unreachable` panic |
|
||||
| `__wbg_randomFillSync` | `crypto/rand` 填 `[]byte` | 本路径未触碰 |
|
||||
| `__wbg_now` | `float64(time.Now().UnixMilli())` | 返回 **f64** |
|
||||
| `__wbg_crypto` / `__wbg_msCrypto` | 返回一个「crypto 对象」标记值 | 只为喂 `getRandomValues` 变体 B |
|
||||
| `__wbg_static_accessor_GLOBAL_THIS` / `GLOBAL` | 返回任意非 0 索引 | |
|
||||
| `__wbg_static_accessor_SELF` / `WINDOW` | **返回 0** | 服务端无 window/self,wasm-bindgen 约定 0 = 缺席 |
|
||||
| `__wbg___wbindgen_is_object` | 类型判断(`[]byte`/`Map`/crypto) | |
|
||||
| `__wbg___wbindgen_is_string` / `is_function` / `is_undefined` | 类型判断 | 后两个本路径未触碰 |
|
||||
| `__wbg___wbindgen_throw` | 记日志(或抛 panic 转 error) | 错误分支 |
|
||||
| `__wbg_Error` | `fmt.Errorf(读出的字符串)` | 错误分支 |
|
||||
| `__wbg_process` / `versions` / `node` / `require` / `call` | 返回 `undefined` / 0 | **实测从未被调用**;但若被调用说明换了 wasm 版本,应告警 |
|
||||
| `__wbindgen_add_to_stack_pointer` / `export1..4` | 由 wasm 自己提供(是 **export**,不是 import) | |
|
||||
|
||||
### 5.6 调用顺序与 ABI 陷阱(都实测过)
|
||||
|
||||
1. `generate_runtime_auth_fields(userJSON)` → `{encrypt_user_info, key}`(各 **172** 字符)
|
||||
2. `qodercontext_new(sp, machineId, len, version, len, userInfo, len, clientMeta, len)`
|
||||
- 返回布局是 **`ptr/errIdx/isErr`**(与字符串类的 `ptr/len/valIdx/isErr` **不同**,混用会得到 `null pointer passed to rust`)
|
||||
3. `qodercontext_prepareInferRequest(sp, ctxPtr, host, len, body, len, key, len, source, len)`
|
||||
- 同样是 `ptr/errIdx/isErr`
|
||||
4. `requestresult_headers(resPtr)` → Map
|
||||
5. `requestresult_url(sp, resPtr)` / `requestresult_body(sp, resPtr)`
|
||||
- ⚠️ **参数顺序是 `(栈指针, ptr)`**,与直觉相反
|
||||
|
||||
另外两条实测纪律:
|
||||
|
||||
- ⚠️ `machineId` 是**必填**:漏传得到 `AES-GCM decrypt failed: aead::Error`,
|
||||
看起来像「密文损坏」,实际是缺参数。
|
||||
- ⚠️ `host` 必须 `api2.qoder.sh` 系;传 `api2-v2.qoder.sh`(公开 OpenAI 兼容端点)
|
||||
会 **404**。
|
||||
|
||||
### 5.7 并发模型
|
||||
|
||||
- **wasm 实例无状态可复用**,实测 `-repeat 50` 全部 PASS;
|
||||
- 但上游注释明确 `QoderContext` **不是线程安全的**,且「一个账号一个实例」;
|
||||
- ⇒ 设计为 **每个账号一个 `QoderContext` + 一把互斥锁**(复用现有 pool 模式),
|
||||
或按 `(uid, machineId)` 做 key 的 `sync.Map`。**不要**单实例全局共享。
|
||||
- 兜底:`objectHeap` 是**只增不回收**的(见 §5.5 drop_ref),长跑需设
|
||||
`qodercontext_free` + 定期重建实例,否则对象表单调增长。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险点
|
||||
|
||||
| # | 风险 | 级别 | 说明 / 缓解 |
|
||||
|---|---|---|---|
|
||||
| R1 | **二进制不在我们仓库,也不可再分发** | 🔴 高 | 需先定 §5.2 的三种来源方案之一;`go:embed` 需法务/许可确认 |
|
||||
| R2 | **账号数 0 ⇒ 端到端不可验证** | 🔴 高 | 本机能证明「产出了签名头」,**不能**证明「上游接受」。必须拿到 1 个 qoder 账号才能做真实验收 |
|
||||
| R3 | wasm 版本漂移 | 🟠 中 | 上游换版本 ⇒ sha256 变,import 表可能变(尤其那 13 个未触碰项)。**必须**启动时校验 sha256 并在版本不符时显式拒绝 |
|
||||
| R4 | `drop_ref` 语义与 wasm-bindgen 版本相关 | 🟠 中 | 本次实测必须 no-op;换 wasm 后需重跑 `tools/qoder-wasm-probe` 复验,否则会退化成 `set on undefined` |
|
||||
| R5 | 对象表只增不回收 ⇒ 内存缓涨 | 🟠 中 | 每个 `QoderContext` 定期重建;`requestresult_*` 用后 `__wbg_requestresult_free` |
|
||||
| R6 | 加密端点意味着**响应也是密文** | 🟠 中 | 需 `decrypt_server_response` 解包,且要处理 SSE 逐帧解密(上游走 `Encode=1`)。这是**第二条**未实现链路,工作量不小 |
|
||||
| R7 | 端点语义与现有明文路径**并存** | 🟡 低-中 | `channels_upstream_gaps_test.go` 现有判据钉住「走明文端点且无信封解包」。接入加密端点必须**同时**改那条判据,否则两套逻辑互相打脸 |
|
||||
| R8 | 引入 wazero 抬升内核版本 | 🟡 低 | 按 §4 的版本纪律,新增 Go 依赖 = 核心版本递增(不是 patch) |
|
||||
| R9 | 体积 +1.16 MiB | 🟡 低 | 桌面端/服务端均可接受,但需确认 `packaging/` 的体积校验阈值 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 预计改动面(为什么不该放进 `fix/` 分支)
|
||||
|
||||
| 层 | 文件(预计) | 性质 |
|
||||
|---|---|---|
|
||||
| 新依赖 | `go.mod` / `go.sum`(wazero) | **内核级** |
|
||||
| 新包 | `internal/qoderwasm/{glue.go,abi.go,context.go,headers.go}` + 测试 | 新代码 ≈ 600–900 行(glue 706 行 TS 的 Go 翻译) |
|
||||
| 内核桥 | `internal/lua/plugin_bridges.go`(新增 kernel action)、`internal/lua/kernel_api.go` 白名单 | **内核级** |
|
||||
| 插件 | `internal/lua/plugins/channels.lua`(`channels-qoder` 增加 `build_headers` + 密文响应解包) | 插件级 |
|
||||
| 判据 | `internal/core/channels_upstream_gaps_test.go`(**必须改**,见 R7)、新增 adapter 判据 | 判据级 |
|
||||
| 部署 | `packaging/`(wasm 放置 + 校验)、`config`(`qoder_wasm_path`) | 运维级 |
|
||||
| 文档 | 本文 + `docs/channel-repair-plan.md` P1-4 段 | 文档级 |
|
||||
|
||||
**结论:这是一个跨 7 个层、含新内核依赖、且需要账号才能验收的主分支级改动。**
|
||||
一条 `fix/channel-auth-headers` 分支承担它会把「已修好的三条头缺陷」
|
||||
一起拖成不可发布状态。⇒ 本轮**不做**,只交付本文 + 可复现工具。
|
||||
|
||||
### 建议的推进顺序(若主管决定做)
|
||||
|
||||
1. **先决策 §5.2**(wasm 从哪来)—— 这是唯一的前置阻塞。
|
||||
2. `internal/qoderwasm` 落地 + 用 `tools/qoder-wasm-probe` 的用例做单测
|
||||
(**离线可测**:不需要账号,断言头集合形状与 sha256 校验)。
|
||||
3. 内核桥 `wasm.headers` + 插件 `build_headers` 接线,**只走明文端点**先跑通
|
||||
头生成链路(不切端点)。
|
||||
4. 拿到 1 个真实账号后,验证「签名头被接受」→ 再决定是否切加密端点。
|
||||
5. 切端点那一轮再处理 R6(响应/SSE 解密)与 R7(判据改写)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实测记录(可复现)
|
||||
|
||||
工具:`tools/qoder-wasm-probe`(**独立 module**,主仓 `go.mod` 不含 wazero)。
|
||||
|
||||
```bash
|
||||
cd tools/qoder-wasm-probe
|
||||
go run . -wasm /opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm
|
||||
# 加 -json 出结构化结果;-repeat N 测实例复用;-strict 让未实现 import 被调用即失败
|
||||
```
|
||||
|
||||
本次输出(2026-10-18,go1.27.1 linux/amd64,wazero v1.9.0):
|
||||
|
||||
```
|
||||
wasm : .../qoder-auth-wasm.wasm
|
||||
size : 298606 bytes
|
||||
imports : 31 (all from "./qoder_auth_wasm_bg.js")
|
||||
exports : 33
|
||||
instantiate: true calls-at-instantiate=0
|
||||
|
||||
-- LEVEL 2: generate_runtime_auth_fields --
|
||||
encrypt_user_info <string len=172>
|
||||
key <string len=172>
|
||||
|
||||
-- LEVEL 3: qodercontext_new + prepareInferRequest --
|
||||
url = api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1
|
||||
body = 60 bytes (encrypted, not logged)
|
||||
hdr Authorization Bearer COSY.eyJ2ZXJzaW9u…<len=209>
|
||||
...(共 20 个头)
|
||||
|
||||
-- IMPORT USAGE (18 of 31 touched) --
|
||||
RESULT: PASS
|
||||
```
|
||||
|
||||
量化(`-repeat 50 -json`):
|
||||
|
||||
| 指标 | 值 |
|
||||
|---|---|
|
||||
| 50 次 `prepareInferRequest` 端到端 | ≈ 108 ms ⇒ **≈ 2.2 ms/请求** |
|
||||
| wasm 线性内存(跑完后) | 20 页 = **1.25 MiB** |
|
||||
| 未触碰 import | **13 / 31** |
|
||||
| 实例复用 | 50 次全部 PASS(无状态泄漏迹象) |
|
||||
|
||||
> **再次声明:以上全部是离线静态/单机结论。**
|
||||
> 「签名头被 qoder 上游接受」**未验证** —— 本机 qoder 账号数 **0**。
|
||||
|
||||
---
|
||||
|
||||
## 9. 与现有实现的冲突(必须在动手前处理)
|
||||
|
||||
`internal/core/channels_upstream_gaps_test.go` 当前把**明文端点**写成了判据:
|
||||
|
||||
> 「qoder 的 chat 端点变了 —— 若改成加密端点 `agent_chat_generation`,
|
||||
> 则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名」
|
||||
|
||||
这条判据是对的(它防的是「只改一半」)。接入 wasm 时**它必须同步演进**,
|
||||
否则会出现「新代码已走加密端点、旧判据仍断言没有信封解包」的自相矛盾。
|
||||
|
||||
> 注意该判据注释里的一处**过时事实**:它写「该二进制在参考仓库里也不存在」。
|
||||
> 实测:文件**存在**于 `vendor/channel-pack/qoder-auth-wasm.wasm`
|
||||
> (sha256 `6419471e…`),只是**不在我们仓库**。接入时应顺手更正这句,
|
||||
> 但**不要**为了「改注释」而放宽判据。
|
||||
109
docs/source-name-contract.md
Normal file
109
docs/source-name-contract.md
Normal file
@ -0,0 +1,109 @@
|
||||
# 源名与限定名契约
|
||||
|
||||
> 本文只说一件事:**源名允许哪些字符,以及为什么**。
|
||||
> 判据:`internal/config/source_name_contract_test.go`、`internal/core/source_name_contract_test.go`。
|
||||
|
||||
## 1. 契约
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 允许字符集 | **仅 `[A-Za-z0-9_-]`** |
|
||||
| 禁止 | **`:`** 与 **`/`**(限定名分隔符,见下) |
|
||||
| 其它禁止 | 空白、换行、`.`、`+`、`@`、非 ASCII 等一切不在允许集内的字符 |
|
||||
| 长度 | 非空(上限未强制,但超长源名无实际用途) |
|
||||
|
||||
合法的真实形态(本仓渠道源):
|
||||
|
||||
```
|
||||
channels-trae-b8dd6b3df30e
|
||||
channels-buddy-buddy-main
|
||||
channels-zcode-<accountId>
|
||||
```
|
||||
|
||||
注意 `-` **是允许的**:渠道源名天生含 `-`,而 `-` 作分隔符时是「源名之后」
|
||||
才出现,不产生歧义。
|
||||
|
||||
## 2. 为什么禁 `:` 与 `/`
|
||||
|
||||
### 2.1 背景:限定名是**有意设计**,不是兼容尾巴
|
||||
|
||||
网关支持 `源名:模型名` 这种**限定名**来指定「从哪个源调用」:
|
||||
|
||||
```
|
||||
channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro
|
||||
```
|
||||
|
||||
这不是历史包袱 —— 生产实例里 **44 个模型被多个源同时提供**
|
||||
(`glm-5.2` 由 6 个源提供),WebUI 的模型下拉框用 `源名:模型名` 作
|
||||
option value。没有限定名,用户就无法指定源。
|
||||
|
||||
`:` / `/` / `-` 三种分隔符都支持(历史写了三种,都保留)。
|
||||
|
||||
### 2.2 根因:源名含分隔符 ⇒ 限定名有**两种都自圆其说**的切法
|
||||
|
||||
剥离源名的那一步是 `internal/provider/splitPinned`:先按**已知源名做最长
|
||||
前缀匹配**,命中后要求源名之后紧跟一个分隔符。
|
||||
|
||||
若源名**自己含** `:` 或 `/`,那个字符同时是「源名的一部分」和「分隔符」:
|
||||
|
||||
| 源名 | 模型 | 限定名 | 可能的切法 |
|
||||
|---|---|---|---|
|
||||
| `a:b` | `m` | `a:b:m` | `("a", "b:m")` 或 `("a:b", "m")` |
|
||||
| `a/b` | `m` | `a/b/m` | `("a", "b/m")` 或 `("a/b", "m")` |
|
||||
|
||||
两种切法都能自圆其说,结果取决于**当前恰好存在哪些源名** —— 也就是
|
||||
同一个客户端请求串,在不同部署、不同启动顺序下可能落到**不同的源**。
|
||||
|
||||
更糟的是它**不会报错**:`splitPinned` 会命中某一个分支,然后路由到别处。
|
||||
历史上这一族缺陷就非常难查:
|
||||
|
||||
> `IndexAny(model, "-:/")` 按**第一个**分隔符切,把
|
||||
> `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 切成 `src="channels"`、
|
||||
> `model="trae-b8dd6b3df30e:..."` ⇒ 源名永不匹配 ⇒ `Resolve()` 返回 nil
|
||||
> ⇒ 客户端看到 `model "channels-trae-xxx:..." is not configured`。
|
||||
> 用户只看到「模型不能用」,完全看不出是字符串切分错了。
|
||||
|
||||
所以约束必须放在**写入口**,而不是每次解析时去猜。
|
||||
|
||||
## 3. 校验加在哪(三个位置,职责不同)
|
||||
|
||||
| 位置 | 函数 | 行为 | 为什么在这儿 |
|
||||
|---|---|---|---|
|
||||
| **写入口(硬拒)** | `core.normalizeSource`(`internal/core/core.go`) | `AddSource` 返回 error(API 400) | WebUI 新增/修改源、插件 `add_source` 都走这里。**新数据必须从源头就是合法的** |
|
||||
| **启动(只告警)** | `config.ApplyDefaults`(`internal/config/config.go`) | `log.Printf` 一行,**不返回 error** | 存量配置可能已有带 `:` 的源名。升级瞬间让网关起不来比「限定名歧义」更坏 —— 整个服务不可用 |
|
||||
| **校验器本体** | `config.ValidateSourceName` / `config.SourceNameWarning` | 纯函数 | 单一定义,上面两处都调它,不会漂移 |
|
||||
|
||||
**解析入口(不在上面三处,但需要知道)**
|
||||
:`internal/provider/registry.go` 的 `splitPinned` 是限定名的**解析**入口,
|
||||
`Resolve` / `ResolvePinned` / `EffectiveModel` 三个都用它。
|
||||
它**不做校验**(解析不该拒绝已有数据),只做最长前缀匹配 + 分隔符判定。
|
||||
校验的责任全在写入口 —— 这样解析路径保持纯函数、无副作用。
|
||||
|
||||
## 4. 存量数据怎么办
|
||||
|
||||
启动时告警、不阻断:
|
||||
|
||||
```
|
||||
[config] source name "legacy:v1" contains ":", which is a reserved pinning
|
||||
separator; "legacy:v1" is ambiguous (source names allow only [A-Za-z0-9_-])
|
||||
— `source:model` pinning may resolve to the wrong source
|
||||
```
|
||||
|
||||
运维看到告警后可以:
|
||||
- 改名(推荐,WebUI 里编辑即可 —— 改名后 `channels-*` 源会在下次账号同步时重建)
|
||||
- 或者继续用,但**不要**对这种源使用 `源名:模型名` 限定名(用「模型 + 源」的
|
||||
显式槽位,即 AUTO 链里的 `(model, source)` 对,那条路径不经过 `splitPinned`)
|
||||
|
||||
## 5. 判据覆盖
|
||||
|
||||
| 判据 | 文件 | 钉住什么 |
|
||||
|---|---|---|
|
||||
| `TestValidateSourceNameAllowsContractCharset` | `internal/config/source_name_contract_test.go` | 合法集(含真实渠道源形态、含 `-`)不被误拒 |
|
||||
| `TestValidateSourceNameRejectsPinningSeparators` | 同上 | `:` / `/` 被**点名**拒绝(错误文案含 `pinning separator` + 回显源名) |
|
||||
| `TestValidateSourceNameRejectsOtherIllegalChars` | 同上 | 空白/换行/`.`/`+`/`@`/非 ASCII/空串 |
|
||||
| `TestSourceNameWarningIsSoftForLegacyConfig` | 同上 | 存量带 `:` 能启动(不 brick),且有告警文案 |
|
||||
| `TestAddSourceRejectsPinnedSeparatorInName` | `internal/core/source_name_contract_test.go` | **真实写入入口** `Core.AddSource` 硬拒,且拒绝发生在写盘之前 |
|
||||
| `TestAddSourceAcceptsChannelStyleNames` | 同上 | 真实渠道源名照常接受(防校验器写太严把渠道全废掉) |
|
||||
|
||||
> 写入口那条特意在 `internal/core` 而不是 `internal/config`:校验器写对了但
|
||||
> **没接到入口上**,问题依旧存在 —— 两条判据分别咬「规则对不对」和「规则有没有被调用」。
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
@ -47,7 +47,7 @@ func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) {
|
||||
// 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 "+
|
||||
t.Errorf("wildcard token rule marked %q as priced at 0 — its unlisted "+
|
||||
"models will bill $0 instead of appearing as unpriced", name)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@ -14,6 +15,67 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// SourceNameAllowedChars 是源名允许的字符集:仅 [A-Za-z0-9_-]。
|
||||
//
|
||||
// 为何必须收窄(本仓真的踩过):模型寻址支持 `源名:模型名` /
|
||||
// `源名/模型名` / `源名-模型名` 三种限定写法(WebUI 下拉框用 `源名:模型名`
|
||||
// 作 option value,因为 44 个模型被多个源同时提供,必须能指定从哪个源调)。
|
||||
// 剥离源名的那一步是 `internal/provider/splitPinned`:它先做**最长源名前缀
|
||||
// 匹配**,再要求源名后紧跟一个分隔符。
|
||||
//
|
||||
// ⇒ 若源名**自己含** `:` 或 `/`,那个字符就同时是「源名的一部分」和「分隔符」,
|
||||
// 限定名就成了歧义串:
|
||||
//
|
||||
// src = "a:b" model = "m" → "a:b:m" 可以被切成 ("a", "b:m") 或 ("a:b", "m")
|
||||
// src = "a/b" model = "m" → "a/b/m" 同上
|
||||
//
|
||||
// 两种切法都能自圆其说,结果取决于**当前恰好有哪些源名** —— 也就是同一个
|
||||
// 客户端请求串在不同部署/不同启动顺序下可能落到不同源。更坑的是它不会报错:
|
||||
// splitPinned 会命中某一个分支然后路由到错的地方(历史缺陷就是这类:
|
||||
// `IndexAny(model, "-:/")` 按第一个分隔符切,导致 channels-trae-xxx:model
|
||||
// 被切成 src="channels" ⇒ 永不匹配 ⇒ `model ... is not configured`)。
|
||||
//
|
||||
// 因此把约束放在**写入口**,而不是每次解析时猜:含 `:` 或 `/` 的源名一律拒。
|
||||
// 注意 `-` 是**允许**的:本仓的渠道源正是 `channels-<平台>-<账号id>`,
|
||||
// 分隔符在源名之后,不产生歧义。
|
||||
const SourceNameAllowedPattern = `[A-Za-z0-9_-]`
|
||||
|
||||
// ValidateSourceName reports why name is not a legal source name, or nil.
|
||||
// Callers turn this into a startup error (config) / API 400 (add_source).
|
||||
func ValidateSourceName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("source name is empty")
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
// allowed
|
||||
case r == ':' || r == '/':
|
||||
// 单独点名:这两个字符是限定名的**分隔符**,出现在源名里必然歧义。
|
||||
return fmt.Errorf(
|
||||
"source name %q contains %q, which is a reserved pinning separator; "+
|
||||
"%q is ambiguous (source names allow only %s)",
|
||||
name, string(r), name, SourceNameAllowedPattern)
|
||||
default:
|
||||
return fmt.Errorf("source name %q contains illegal character %q (allowed: %s)",
|
||||
name, string(r), SourceNameAllowedPattern)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SourceNameWarning is the soft form of ValidateSourceName: it returns a
|
||||
// human-readable warning for names that BREAK the contract but existed before
|
||||
// the contract did. Startup logs it instead of failing, so an operator's
|
||||
// already-working config is not bricked by an upgrade; new sources are refused
|
||||
// outright (see core.normalizeSource).
|
||||
func SourceNameWarning(name string) string {
|
||||
if err := ValidateSourceName(name); err != nil {
|
||||
return err.Error() + " — `source:model` pinning may resolve to the wrong source"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Config is the top-level gateway configuration.
|
||||
type Config struct {
|
||||
Path string `yaml:"-" json:"-"`
|
||||
@ -73,15 +135,20 @@ 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.
|
||||
@ -325,6 +392,14 @@ func (c *Config) ApplyDefaults() error {
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("config: sources[%d] missing name", i)
|
||||
}
|
||||
// ★ 契约(见 ValidateSourceName):源名只允许 [A-Za-z0-9_-],
|
||||
// 含 `:` 或 `/` 会让 `源名:模型名` 变歧义。
|
||||
// 这里**只告警不当场报错**:存量配置可能已经有这种名字,升级直接
|
||||
// 启动失败会让整个网关不可用(比歧义更坏)。新增源由
|
||||
// core.normalizeSource 硬拒 —— 契约从新数据开始强制。
|
||||
if warn := SourceNameWarning(s.Name); warn != "" {
|
||||
log.Printf("[config] %s", warn)
|
||||
}
|
||||
if s.BaseURL == "" {
|
||||
return fmt.Errorf("config: source %s missing base_url", s.Name)
|
||||
}
|
||||
@ -373,13 +448,14 @@ type RuntimeConfig struct {
|
||||
// name and api_key) so the WebUI can spin up multiple key-bearing sources
|
||||
// from one shared template.
|
||||
type SourceTemplate struct {
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Adapter string `json:"adapter"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
ImageEndpoint string `json:"image_endpoint,omitempty"`
|
||||
Models []Model `json:"models"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Adapter string `json:"adapter"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
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"`
|
||||
@ -393,18 +469,18 @@ type SourceTemplate struct {
|
||||
// (full management) or "user" (sees only its own key); Models is the allowed
|
||||
// model scope with per-model token quota (0 = unlimited).
|
||||
type GWKey struct {
|
||||
Key string `yaml:"key" json:"key"`
|
||||
Role string `yaml:"role" json:"role"`
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// HasOwnAuto reports whether the key declares its own AUTO chain rather than
|
||||
|
||||
107
internal/config/source_name_contract_test.go
Normal file
107
internal/config/source_name_contract_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ P2:源名允许字符集契约 —— 仅 `[A-Za-z0-9_-]`,**不得含 `:` 与 `/`**。
|
||||
//
|
||||
// 为何必须收窄(原因写在这里,因为 docs 会被改、判据不会):
|
||||
//
|
||||
// 模型寻址支持 `源名:模型名` / `源名/模型名` / `源名-模型名` 三种限定写法
|
||||
// (WebUI 下拉框用 `源名:模型名` 作 option value:本实例 44 个模型被多个源
|
||||
// 同时提供,必须能指定「从哪个源调」)。剥离源名的那一步是
|
||||
// `internal/provider/splitPinned`:先做**最长源名前缀匹配**,再要求源名后
|
||||
// 紧跟一个分隔符。
|
||||
//
|
||||
// ⇒ 若源名**自己含** `:` 或 `/`,那个字符同时是「源名的一部分」和「分隔符」,
|
||||
// 限定名就成了歧义串:
|
||||
//
|
||||
// src="a:b" model="m" → "a:b:m" → ("a", "b:m") 还是 ("a:b", "m")?
|
||||
// src="a/b" model="m" → "a/b/m" → ("a", "b/m") 还是 ("a/b", "m")?
|
||||
//
|
||||
// 两种切法都自圆其说,结果取决于**当前恰好有哪些源名** —— 同一客户端请求串
|
||||
// 在不同部署/不同启动顺序下可能落到不同源,而且**不会报错**(splitPinned
|
||||
// 会命中某一个分支然后路由到别处)。历史缺陷正是这一族:`IndexAny(model,
|
||||
// "-:/")` 按第一个分隔符切,把 `channels-trae-xxx:model` 切成 src="channels"
|
||||
// ⇒ 永不匹配 ⇒ `model ... is not configured`。
|
||||
//
|
||||
// 注意 `-` 是**允许**的:本仓渠道源就是 `channels-<平台>-<账号id>`,
|
||||
// 分隔符在源名之后,不产生歧义。
|
||||
func TestValidateSourceNameAllowsContractCharset(t *testing.T) {
|
||||
allowed := []string{
|
||||
"channels-trae-b8dd6b3df30e", // 真实渠道源形态(含两个 -)
|
||||
"channels-buddy-buddy-main",
|
||||
"DeepSeek",
|
||||
"my_source_1",
|
||||
"a",
|
||||
"-leading-dash",
|
||||
"trailing-dash-",
|
||||
"123456",
|
||||
}
|
||||
for _, name := range allowed {
|
||||
if err := ValidateSourceName(name); err != nil {
|
||||
t.Errorf("★ 合法源名 %q 被拒: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 反例:`:` 与 `/` 必须**被点名拒绝**(而不是泛泛地说"非法字符")——
|
||||
// 运维看到错误信息才知道这是限定名分隔符冲突,不是手滑。
|
||||
func TestValidateSourceNameRejectsPinningSeparators(t *testing.T) {
|
||||
cases := []string{
|
||||
"a:b",
|
||||
"vendor:model",
|
||||
"a/b",
|
||||
"openai/gpt",
|
||||
"channels-trae-b8dd6b3df30e:glm-5.2", // 把"限定名"当源名写进来
|
||||
"src/sub:name",
|
||||
}
|
||||
for _, name := range cases {
|
||||
err := ValidateSourceName(name)
|
||||
if err == nil {
|
||||
t.Fatalf("★ 源名 %q 含限定名分隔符,必须被拒(否则 `源名:模型名` 歧义)", name)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "pinning separator") {
|
||||
t.Errorf("错误信息应点名「pinning separator」,实得: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, name) {
|
||||
t.Errorf("错误信息应回显源名 %q,实得: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其它非法字符同样拒(防止 `源 名`、`源名\n` 这种把日志/URL 搞乱的写法)。
|
||||
func TestValidateSourceNameRejectsOtherIllegalChars(t *testing.T) {
|
||||
for _, name := range []string{"has space", "tab\there", "new\nline", "中文名", "dot.name", "plus+name", "@at"} {
|
||||
if err := ValidateSourceName(name); err == nil {
|
||||
t.Errorf("★ 源名 %q 含非法字符,必须被拒(允许集只有 %s)", name, SourceNameAllowedPattern)
|
||||
}
|
||||
}
|
||||
if err := ValidateSourceName(""); err == nil {
|
||||
t.Error("空源名必须被拒")
|
||||
}
|
||||
}
|
||||
|
||||
// 存量兼容:启动时**只告警不报错**。
|
||||
//
|
||||
// 为什么不能直接报错:存量配置里可能已经有带 `:` 的源名,升级瞬间让网关
|
||||
// 起不来(比"限定名歧义"更坏 —— 整个服务不可用)。契约从**新数据**开始强制
|
||||
// (core.normalizeSource 硬拒),老数据给一条可读告警。
|
||||
func TestSourceNameWarningIsSoftForLegacyConfig(t *testing.T) {
|
||||
cfg := Config{Sources: []Source{
|
||||
// 存量形态:带 `:` —— 必须能启动(不返回 error),但要被点名告警
|
||||
{Name: "legacy:v1", BaseURL: "http://x", Models: []Model{{ID: "m1"}}},
|
||||
}}
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
t.Fatalf("★ 存量配置带 `:` 不得导致启动失败(会把整个网关 brick 掉): %v", err)
|
||||
}
|
||||
if w := SourceNameWarning("legacy:v1"); w == "" {
|
||||
t.Fatal("★ 存量非法名必须给出告警文案(否则问题彻底静默)")
|
||||
}
|
||||
if w := SourceNameWarning("channels-trae-abc"); w != "" {
|
||||
t.Fatalf("合法源名不该产生告警,实得 %q", w)
|
||||
}
|
||||
}
|
||||
68
internal/core/adapter_install_probe_test.go
Normal file
68
internal/core/adapter_install_probe_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 适配器**真实装载**必须可观测:`__adapter_install_probe` 要报告全部 6 个
|
||||
// 自装适配器,且每个都是 ok。
|
||||
//
|
||||
// 为什么这条判据值得存在(plan §3 第 1 条):
|
||||
//
|
||||
// `luac -p` 对 Lua 长字符串里的适配器代码**无效**(它只看外层插件脚本),
|
||||
// 会给出假绿。真实故障态是「插件加载成功、适配器却没装进去」—— 此时每次
|
||||
// 调用内核才响亮报 `missing transform_response`,而原因指向内核不像配置。
|
||||
//
|
||||
// 另一层:cline / qoder 此前**不在探针输出里**,于是「装了但没生效」在这两个
|
||||
// 适配器上完全不可观测 —— 本轮修 cline 的 `workos:` 前缀时正是因此先卡了一下。
|
||||
// 装上就该看得见。
|
||||
func TestAdapterInstallProbeReportsAllSelfInstalled(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.Fatalf("加载插件: %v", err)
|
||||
}
|
||||
|
||||
res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("__adapter_install_probe: %v", err)
|
||||
}
|
||||
// 探针的诊断价值就在于它能在 CI 里跑通(zcode 的基座在 core 测试台里
|
||||
// 是可得 —— pluginDir 与 adaptersDir 同一套配置)。
|
||||
top, ok := res.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针返回值不是对象: %#v", res)
|
||||
}
|
||||
j, ok := top["json"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针返回值缺 json 层: %#v", top)
|
||||
}
|
||||
|
||||
// 6 个自装适配器:缺任何一个都是"插件装了、适配器没装"的故障态。
|
||||
want := []string{"codearts", "trae", "gemini", "zcode", "cline", "qoder"}
|
||||
for _, name := range want {
|
||||
entry, ok := j[name].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("★ 探针未报告 %s 适配器的装载结果(装上就该看得见;"+
|
||||
"漏报等于问题不可观测): %v", name, j)
|
||||
}
|
||||
if entry["ok"] != true {
|
||||
t.Fatalf("★ %s 适配器装载失败: %v\n"+
|
||||
" luac -p 对长字符串里的适配器代码**无效**(plan §3 第 1 条),"+
|
||||
"必须以本探针为准", name, entry["err"])
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 6/6 自装适配器全部装载成功(probe: %v)", keysOfProbe(j))
|
||||
}
|
||||
|
||||
func keysOfProbe(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
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() }
|
||||
85
internal/core/channels_buddy_catalog_filter_test.go
Normal file
85
internal/core/channels_buddy_catalog_filter_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
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 类别名")
|
||||
}
|
||||
|
||||
// ★ 已实测**不可调用**的模型必须也从目录里排除 —— 包括 agentRef 例外路径。
|
||||
//
|
||||
// 真实缺陷:`hy4-preview-x` 被服务端 agents 引用,于是 agentRef 例外把它放回
|
||||
// 目录;而它对上游**恒回 11102**(`model service info not found`,加/不加
|
||||
// reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。
|
||||
// 用户选中就 400,错误文案指向模型 ⇒ 极易误判"模型坏了"。
|
||||
//
|
||||
// 参考用**白名单重建**(product.ts 的兜底表里没有它)达到同样效果。
|
||||
func TestBuddyCatalogDropsKnownUncallable(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
if !strings.Contains(s, "local NOT_CALLABLE = {") {
|
||||
t.Fatal("必须有已实测不可调用的排除表(等价于参考的白名单)")
|
||||
}
|
||||
if !strings.Contains(s, `["hy4-preview-x"] = true`) {
|
||||
t.Error("hy4-preview-x 实测恒 11102,必须排除")
|
||||
}
|
||||
// 闸门必须生效在**所有**入口 —— 尤其是 agentRef 例外之后
|
||||
if !strings.Contains(s, "if NOT_CALLABLE[id] then return false end") {
|
||||
t.Error("NOT_CALLABLE 闸门必须在 buddyIsChatModel 内(覆盖 models 与 agentRef 两条路径)")
|
||||
}
|
||||
t.Log("✓ 不可调用模型已排除,且 agentRef 例外也过闸")
|
||||
}
|
||||
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"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
73
internal/core/channels_lua_locals_test.go
Normal file
73
internal/core/channels_lua_locals_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ channels.lua 必须留出顶层 local 余量 —— 否则**下一次顶层改动就编译失败**。
|
||||
//
|
||||
// 实测事实(2026-10-16):该文件的主 chunk 曾**顶到 Lua 上限 200**,
|
||||
// 往顶部加**哪怕 1 个**顶层 local 就报
|
||||
//
|
||||
// main function has more than 200 local variables
|
||||
//
|
||||
// 而这炸的不是某个功能 —— 是**整个渠道插件**(插件加载失败 ⇒ 全渠道不可用)。
|
||||
//
|
||||
// ⚠️ 判据为什么用 luajit 而不是 luac:
|
||||
//
|
||||
// 内核链接的是 libluajit-5.1.so.2;而 `luac`(Lua 5.4)对同一文件给出
|
||||
// **不同的**结论。用错工具会得到假绿。
|
||||
//
|
||||
// ⚠️ 判据为什么是「注入 N 个 local 仍能编译」而不是「数一遍 local」:
|
||||
//
|
||||
// 主 chunk 的 local 数受**作用域嵌套**影响,静态计数不可靠(同一个文件
|
||||
// 用不同脚本数出 228/232/248 三个数)。而"能不能再加"是**决定性**的,
|
||||
// 也正是我们真正需要的性质。
|
||||
func TestChannelsLuaHasLocalHeadroom(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := exec.LookPath("luajit"); err != nil {
|
||||
t.Skip("luajit 不在 PATH(判据需要与内核相同的 Lua 实现)")
|
||||
}
|
||||
|
||||
const headroom = 10
|
||||
for _, n := range []int{1, headroom} {
|
||||
probe := strings.Repeat("local __headroom = 1\n", n) + src
|
||||
cmd := exec.Command("luajit", "-e", "local f,e=loadfile(arg[1]); if not f then io.stderr:write(tostring(e)) os.exit(1) end", "/dev/stdin")
|
||||
cmd.Stdin = strings.NewReader(probe)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("★ 顶层 local 余量不足:加 %d 个 local 就无法编译 ⇒ "+
|
||||
"下一次顶层改动会**整插件加载失败**(全渠道不可用)\n %s\n"+
|
||||
"修法:把纯常量收进 plugin.K 表(挂表字段不占 local 槽位)", n, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
t.Logf("✓ channels.lua 仍有 ≥%d 个顶层 local 余量", headroom)
|
||||
}
|
||||
|
||||
// 配对判据:常量必须确实在 plugin.K 里(防止有人把余量"省"出来的同时又
|
||||
// 把常量改回裸 local —— 那会让下一个改动的余量再次归零)。
|
||||
func TestChannelsConstantsLiveInPluginK(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
if !strings.Contains(s, "plugin.K = {") {
|
||||
t.Fatal("必须有 plugin.K 常量表(它是 local 余量的来源)")
|
||||
}
|
||||
// 抽样:这些曾占用 local 槽位的纯常量必须已挂表
|
||||
for _, n := range []string{"STORE_NAME", "SESSION_TTL", "B64_ALPHABET", "MODELS_DEV_URL"} {
|
||||
if !regexp.MustCompile(`(?m)^\s+` + n + ` = `).MatchString(s) {
|
||||
t.Errorf("%s 应定义在 plugin.K 表内", n)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^local ` + n + ` = `).MatchString(s) {
|
||||
t.Errorf("%s 又变回裸 local 了(会吃掉 local 余量)", n)
|
||||
}
|
||||
}
|
||||
t.Log("✓ 纯常量已集中在 plugin.K")
|
||||
}
|
||||
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{}
|
||||
169
internal/core/channels_orphan_source_test.go
Normal file
169
internal/core/channels_orphan_source_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 孤儿源必须能被识别、且**只删该删的**。
|
||||
//
|
||||
// 真实事故(2026-10-17 生产实测):`channels-buddy-585b335b64cf` 的账号早已删除,
|
||||
// 源却仍在 config.yaml 里对外服务 —— 独占服务 hy4-preview-x(404),
|
||||
// 对 6 个共享模型则排在正常源**前面**先被尝试(每次白付 ~3.4s 上游往返)。
|
||||
//
|
||||
// 这是 v2.0.28(remove_source 漏声明)的**存量残骸**:修声明只防新增,不清理存量。
|
||||
func TestChannelsReapOrphanSources(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.Fatalf("加载插件: %v", err)
|
||||
}
|
||||
// 生产里 channels 被授予这些(见 plugin.kernel 声明);夹具必须同样授权,
|
||||
// 否则 add_source 会被拒 ⇒ 造不出孤儿源。
|
||||
if err := ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil {
|
||||
t.Fatalf("授权: %v", err)
|
||||
}
|
||||
|
||||
// 装载前先清空账号池:这样插件初始化流程自己注册的源也一并算孤儿。
|
||||
cleanup := func() interface{} {
|
||||
r, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{
|
||||
"dry_run": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reap_orphan_sources: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
cleanup()
|
||||
|
||||
// 造出**真实形态**:走 account_add 登记账号(它会注册源),
|
||||
// 再用 hidden 探针把账号从池里摘掉 —— 模拟 v2.0.28 事故的残留形态
|
||||
// (账号已删、撤源被拒 ⇒ 源留下继续对外服务)。
|
||||
// 不走 account_delete:那条路径的收尾已被 TestAccountDelete* 覆盖,
|
||||
// 而我们要的恰恰是"源留下了"这个状态。
|
||||
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "deadbeef1234", "credential": "test-cred",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("account_add 报错: %v", j["error"])
|
||||
}
|
||||
// ★ 第二个孤儿:账号 id **含 `-`**。这个用例是判别性的 ——
|
||||
// 用 `name:match("^channels%-(.+)-(.+)$")` 解析会被 Lua 的**贪婪** `-`
|
||||
// 切成 plat="buddy-dead"/acct="acct"(未知平台)⇒ 源被跳过 ⇒ **孤儿漏杀**。
|
||||
// (实测:只有这个用例能把"按已知平台前缀匹配"与"贪婪匹配"区分开;
|
||||
// 不含 `-` 的 id 两种实现结果相同,咬不住。)
|
||||
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "dead-acct-1", "credential": "test-cred",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add(2): %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("account_add(2) 报错: %v", j["error"])
|
||||
}
|
||||
// 运维手工源:名字与账号源形态无关 ⇒ 绝不碰
|
||||
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
|
||||
"name": "my-handmade-source", "base_url": "http://127.0.0.1:1",
|
||||
"adapter": "openai", "models": []interface{}{"m1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册手工源: %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
// ★ 必须看 json 里的 error:内核把失败放在返回体里,
|
||||
// Go 侧 err 仍是 nil ⇒ 只查 err 会让"源根本没注册"静默通过。
|
||||
t.Fatalf("注册手工源报错: %v", j["error"])
|
||||
}
|
||||
// 摘账号(保留源)
|
||||
for _, id := range []string{"deadbeef1234", "dead-acct-1"} {
|
||||
if _, err := ps.Call("channels", "__forget_account_probe", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
}); err != nil {
|
||||
t.Fatalf("摘账号 %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ① dry_run(默认)只报告、不删
|
||||
res, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
orphans, _ := j["orphans"].([]interface{})
|
||||
seen := map[string]bool{}
|
||||
for _, o := range orphans {
|
||||
if s, _ := o.(string); s != "" {
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
|
||||
if !seen[want] {
|
||||
t.Fatalf("★ 没识别出孤儿源 %s(dry_run 应报告它)\n"+
|
||||
" 若 id 含 `-` 的那个漏了 ⇒ 说明用了贪婪 match 解析名字,应改为按已知平台前缀匹配\n"+
|
||||
" 实得 %v", want, seen)
|
||||
}
|
||||
}
|
||||
if seen["my-handmade-source"] {
|
||||
t.Fatalf("★ 误伤运维手工源(名字不匹配账号源形态,绝不能碰)")
|
||||
}
|
||||
if j["dry_run"] != true {
|
||||
t.Fatalf("dry_run 必须默认开启(破坏性操作不得默认生效),实得 %v", j["dry_run"])
|
||||
}
|
||||
if j["removed"].(float64) != 0 {
|
||||
t.Fatalf("dry_run=true 时不得真删,实得 removed=%v", j["removed"])
|
||||
}
|
||||
|
||||
// ② 确认 dry_run 真的没删(看源列表)
|
||||
after, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := sourceNames(jsonOf(t, after))
|
||||
if !names["channels-buddy-deadbeef1234"] {
|
||||
t.Fatalf("dry_run 竟真删了!源列表: %v", names)
|
||||
}
|
||||
// 手工源必须存活(名字不匹配账号源形态 ⇒ 不该被识别为孤儿)
|
||||
if !names["my-handmade-source"] {
|
||||
t.Fatalf("dry_run 时就丢了手工源?源列表: %v", names)
|
||||
}
|
||||
|
||||
// ③ 真删:孤儿消失、手工源存活
|
||||
res, err = ps.Call("channels", "reap_orphan_sources", map[string]interface{}{"dry_run": false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j = jsonOf(t, res)
|
||||
if j["ok"] != true {
|
||||
t.Fatalf("清理应成功,实得 %v", j)
|
||||
}
|
||||
after, err = ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = sourceNames(jsonOf(t, after))
|
||||
for _, dead := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
|
||||
if names[dead] {
|
||||
t.Fatalf("★ 孤儿源 %s 未被清掉(它会继续对外服务无凭据的请求): %v", dead, names)
|
||||
}
|
||||
}
|
||||
if !names["my-handmade-source"] {
|
||||
t.Fatalf("★ 误删了运维手工源: %v", names)
|
||||
}
|
||||
t.Logf("✓ 孤儿源已清、手工源存活")
|
||||
}
|
||||
|
||||
// sourceNames 把 __list_sources_probe 的返回摊平成 name→true。
|
||||
func sourceNames(j map[string]interface{}) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if arr, ok := j["sources"].([]interface{}); ok {
|
||||
for _, x := range arr {
|
||||
if m, ok := x.(map[string]interface{}); ok {
|
||||
if s, ok := m["name"].(string); ok {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
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
|
||||
}
|
||||
117
internal/core/channels_trae_chat_headers_test.go
Normal file
117
internal/core/channels_trae_chat_headers_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 的**推理请求**必须用 Cloud-IDE-JWT 头族,不是 Bearer。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
//
|
||||
// Doubao-Seed-2.1-Pro / seed-code-pro-0430 / glm-5-turbo 全部
|
||||
// 401「We're sorry, but we are not able to authenticate you」
|
||||
// 因为 channels-trae 适配器**只有 4 个 transform、没有 build_headers**
|
||||
// ⇒ 内核兜底发 `Authorization: Bearer <api_key>`。
|
||||
//
|
||||
// 参考 trae.ts 端点表:对话也是 Cloud-IDE-JWT(traeSOLOHeaders)。
|
||||
//
|
||||
// ★ 这与「额度不可用」是**同一根因的两个出口**:额度端点(checkin_status /
|
||||
//
|
||||
// balance)漏了头、已修;推理端点(本判据)也漏了,但当时没一起修。
|
||||
// ⇒ 教训:修「某一族请求的鉴权」时必须把所有**该族的出口**列出来逐个对齐。
|
||||
func TestTraeChatHeadersUseCloudIDEJWT(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.Fatalf("加载插件: %v", err)
|
||||
}
|
||||
|
||||
// 走真实的 BuildHeaders(内核调用适配器的那条路径)
|
||||
hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{
|
||||
"api_key": "ACCESS-TOKEN-X",
|
||||
"source": map[string]interface{}{
|
||||
"name": "channels-trae-test",
|
||||
"meta": map[string]interface{}{
|
||||
"uid": "3428695215186675",
|
||||
"device_id": "dev-abc",
|
||||
"machine_id": "mach-xyz",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
auth := hdrs["Authorization"]
|
||||
if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") {
|
||||
t.Fatalf("★ 推理请求鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+
|
||||
" 用 Bearer 会得到 401「not able to authenticate you」,"+
|
||||
"表现为「channel 注册的模型全都不能用」", auth)
|
||||
}
|
||||
if !strings.Contains(auth, "ACCESS-TOKEN-X") {
|
||||
t.Fatalf("Authorization 应携带 access_token,实得 %q", auth)
|
||||
}
|
||||
// 设备身份头必须来自 meta(适配器沙箱读不到凭据)
|
||||
for k, want := range map[string]string{
|
||||
"X-Uid": "3428695215186675",
|
||||
"X-Device-Id": "dev-abc",
|
||||
"X-Machine-Id": "mach-xyz",
|
||||
} {
|
||||
if got := hdrs[k]; got != want {
|
||||
t.Fatalf("★ %s 应为 %q(来自源 meta),实得 %q", k, want, got)
|
||||
}
|
||||
}
|
||||
// 参考 traeSOLOHeaders 的成对 token 头
|
||||
for _, k := range []string{"X-Cloudide-Token", "X-Ide-Token"} {
|
||||
if hdrs[k] != "ACCESS-TOKEN-X" {
|
||||
t.Fatalf("%s 应与 access_token 相同,实得 %q", k, hdrs[k])
|
||||
}
|
||||
}
|
||||
t.Logf("✓ trae 推理头: %s(X-Uid=%s)", auth, hdrs["X-Uid"])
|
||||
}
|
||||
|
||||
// 配对判据:源 meta 缺失时不得崩、也要给出 Cloud-IDE-JWT(只是没有设备头)。
|
||||
func TestTraeChatHeadersTolerateMissingMeta(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)
|
||||
}
|
||||
hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{
|
||||
"api_key": "T",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders 不该因缺 meta 报错: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(hdrs["Authorization"], "Cloud-IDE-JWT ") {
|
||||
t.Fatalf("即使没有 meta 也必须是 Cloud-IDE-JWT,实得 %q", hdrs["Authorization"])
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 源必须携带设备身份(meta),否则适配器拿不到 uid。
|
||||
func TestTraeSourceCarriesDeviceIdentityInMeta(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "source_meta_from_credential") {
|
||||
t.Fatal("trae 平台应声明 source_meta_from_credential=true —— " +
|
||||
"否则源的 meta 为空、适配器无法派生 X-Uid / X-Device-Id")
|
||||
}
|
||||
if !strings.Contains(s, "src.meta") {
|
||||
t.Fatal("syncAccountSource 应把设备身份写进 src.meta")
|
||||
}
|
||||
}
|
||||
|
||||
var _ = lua.HandlerRequest{}
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
71
internal/core/channels_trae_credits_hdr_test.go
Normal file
71
internal/core/channels_trae_credits_hdr_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ TRAE 的**签到状态 / 积分余额**必须用 Cloud-IDE-JWT + 设备身份头,
|
||||
// 不能用默认的 `Authorization: Bearer`。
|
||||
//
|
||||
// 真实缺陷(用户报「显示额度不可用」,2026-10-17):
|
||||
//
|
||||
// 同一账号**签到领取成功**(claim_fn 走 traceCheckinHeaders),
|
||||
// 但 credits/status 返回
|
||||
// "We're sorry, but we are not able to authenticate you..."
|
||||
// 因为 checkin_status / balance 既没 auth 也没 headers_fn
|
||||
// ⇒ fetchAccountJSON 退回默认 Bearer。
|
||||
//
|
||||
// 参考 trae.ts 端点表逐行写明这三个端点都是 Cloud-IDE-JWT。
|
||||
//
|
||||
// ⚠️ 只加 `auth = "cloud-ide-jwt"` **没用**:该字段只被 claim_fn 的绑定循环
|
||||
//
|
||||
// 读取(line ~9033),fetchAccountJSON 只认 headers_fn —— 第一版我就只加了
|
||||
// 声明,等于没改。
|
||||
//
|
||||
// ⚠️ 为什么不用 httptest 假上游断言:那需要 set_endpoints 改 URL + 凭据落到
|
||||
//
|
||||
// 池账号 ref + 路径匹配全部对齐,任一环节错都会让"没发请求"看起来像
|
||||
// "头不对"(我在这上面耗了三轮,全是夹具问题)。判据直接咬**被测对象**:
|
||||
// spec.headers_fn 的产出。
|
||||
func TestTraeCreditsEndpointsUseCloudIDEJWT(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.Fatalf("加载插件: %v", err)
|
||||
}
|
||||
|
||||
// 通过真实函数取 trae 的两个 spec 的 headers_fn 产出。
|
||||
// 凭据带 uid ⇒ 设备身份头由 uid 确定性派生。
|
||||
out, err := ps.Call("channels", "__cred_headers_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"credential": `{"access_token":"ACCESS-X","uid":"3428695215186675",` +
|
||||
`"device_id":"d1","machine_id":"m1"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("__cred_headers_probe: %v", err)
|
||||
}
|
||||
j := jsonOf(t, out)
|
||||
for _, which := range []string{"checkin_status", "balance"} {
|
||||
h, ok := j[which].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针应回报 %s 的头,实得 %v", which, j)
|
||||
}
|
||||
auth, _ := h["Authorization"].(string)
|
||||
if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") {
|
||||
t.Fatalf("★ %s 的鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+
|
||||
" 用默认 Bearer 会得到「not able to authenticate you」,"+
|
||||
"而同一账号的签到领取却是成功的 —— 这个矛盾就是缺陷", which, auth)
|
||||
}
|
||||
for _, k := range []string{"X-Device-Id", "X-Market-User-Id", "X-User-Region"} {
|
||||
if v, _ := h[k].(string); v == "" {
|
||||
t.Fatalf("★ %s 缺 %s —— 参考实测简化头会失败", which, k)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ %s: %s (device=%v)", which, auth, h["X-Device-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{}
|
||||
}
|
||||
124
internal/core/channels_trae_exchange_shape_test.go
Normal file
124
internal/core/channels_trae_exchange_shape_test.go
Normal file
@ -0,0 +1,124 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE ExchangeToken 的凭据在 **Result 里**,不在顶层。
|
||||
//
|
||||
// 真实缺陷(用户报「trae 登录还是不行」,2026-10-17):
|
||||
//
|
||||
// 会话落定为 state=failed,message = "TRAE ExchangeToken 响应缺少 Token"
|
||||
// 而实测上游 HTTP 200,body 是:
|
||||
// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}}
|
||||
// 代码读 `rec.Token` ⇒ 恒 nil ⇒ 误报「缺少 Token」。
|
||||
// 参考 parseTraeExchangeResponse 明确先取 `data.Result ?? data.result`。
|
||||
//
|
||||
// ★★ 为什么此前所有 trae 判据都是绿的(这个缺陷藏了很久的原因):
|
||||
//
|
||||
// **测试夹具返回的是扁平 `{"Token":...}`** —— 与生产形状不一致,
|
||||
// 于是「只读顶层」这个错误在夹具里恰好是对的。夹具形状错 = 判据必然虚假通过。
|
||||
// 已把夹具改成真实 Result 形状(channels_trae_test.go)。
|
||||
//
|
||||
// 本判据直接用**两种形状**分别打一次,确保实现同时认 Result 与顶层兜底。
|
||||
func TestTraeExchangeReadsResultEnvelope(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "生产形状:凭据在 Result 里",
|
||||
body: `{"ResponseMetadata":{"RequestId":"x"},"Result":{"Token":"ACCESS-FROM-RESULT",` +
|
||||
`"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000}}`,
|
||||
want: "ACCESS-FROM-RESULT",
|
||||
},
|
||||
{
|
||||
name: "兜底:凭据在顶层(上游若改版)",
|
||||
body: `{"Token":"ACCESS-FROM-TOP","RefreshToken":"RT-TOP"}`,
|
||||
want: "ACCESS-FROM-TOP",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// 覆盖夹具的响应体为这个形状
|
||||
atomic.StoreInt32(&h.exchOK, 1)
|
||||
h.overrideExchangeBody(tc.body)
|
||||
|
||||
// ★ 必须走**池路径**(UI 真实流程):先建占位账号再带 account_id
|
||||
// 发登录。beginAndCallback 不带 account_id ⇒ 凭据落到
|
||||
// `channels:trae`(无池账号),也就无法回读 —— 我第一版就这么写,
|
||||
// 判据报"池里应有账号,实得 0",与真因(形状)无关。
|
||||
aid, nonce := h.addAccountAndStartLogin("trae")
|
||||
status, body := h.invokeCallback(map[string]string{
|
||||
"state": nonce, "refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("回调应成功:status=%d body=%s", status, body)
|
||||
}
|
||||
if strings.Contains(body, "缺少 Token") {
|
||||
t.Fatalf("★ 又没解出 Token(形状=%s)—— 检查是否只读了顶层", tc.name)
|
||||
}
|
||||
// 凭据必须真的落进 secrets,且新 refresh 被轮换保存。
|
||||
// ⚠️ 账号 id 是**随机生成**的(account_add 用 newToken():sub(1,12)),
|
||||
// 不能硬编码 —— 用 account_list 拿真实 id。
|
||||
cred := h.credentialFor(aid)
|
||||
if !strings.Contains(cred, tc.want) {
|
||||
t.Fatalf("落盘凭据里应有 access token %q,实得 %q", tc.want, cred)
|
||||
}
|
||||
if !strings.Contains(cred, "refresh_token") {
|
||||
t.Fatalf("★ 轮换后的 refreshToken 必须保存 —— 旧 refresh 已被上游作废,"+
|
||||
"不存新值会让下次续期必然失败。实得 %q", cred)
|
||||
}
|
||||
t.Logf("✓ %s → access=%s", tc.name, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// overrideExchangeBody 让夹具的 ExchangeToken 返回指定 body。
|
||||
func (h *traeHarness) overrideExchangeBody(body string) {
|
||||
h.t.Helper()
|
||||
h.exchBody = body
|
||||
}
|
||||
|
||||
// credentialFor 读回某账号落盘的凭据原文。
|
||||
//
|
||||
// 用 Plugins.GetSecret(内核级读取)而不是 __read_cred_probe:
|
||||
// 探针那版我传错过入参(它吃 ref),返回 len:0,让判据看起来像"凭据没落盘"。
|
||||
// GetSecret 是 TestTraeCallbackPoolPath 已在用的正规入口。
|
||||
// addAccountAndStartLogin 建占位账号并带 account_id 发起登录,返回 (账号id, nonce)。
|
||||
// 这是 UI 真实流程(见 TestTraeCallbackPoolPath)。
|
||||
func (h *traeHarness) addAccountAndStartLogin(platform string) (string, string) {
|
||||
h.t.Helper()
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":%q}`, platform),
|
||||
})
|
||||
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":%q,"account_id":%q}`, platform, aid),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
nonce, _ := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
|
||||
return aid, nonce
|
||||
}
|
||||
|
||||
func (h *traeHarness) credentialFor(accountID string) string {
|
||||
h.t.Helper()
|
||||
v, ok, err := h.ps.GetSecret("channels", "channels:trae:"+accountID)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
130
internal/core/channels_trae_solo_sse_test.go
Normal file
130
internal/core/channels_trae_solo_sse_test.go
Normal file
@ -0,0 +1,130 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ TRAE 的流式转换必须把 **SOLO 自定义 SSE** 摊平成内核要的 **UnifiedChunk**。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
//
|
||||
// ① 我原先 `return __prelude.openai_stream_chunk(raw_chunk)` —— SOLO 帧形如
|
||||
// {"response":"###","reasoning_content":null,"tool_calls":null}
|
||||
// 没有 `choices` 字段 ⇒ 每帧都解析不出 ⇒ 上游 HTTP 200 + 118 正常帧,
|
||||
// 客户端却得到 `empty stream`。
|
||||
// ② 修了 ① 之后我返回 OpenAI 外壳 `{choices:[{delta:{...}}]}` —— 仍不对:
|
||||
// 内核 `json.Unmarshal(unified, &UnifiedChunk)` 直接解成**扁平结构**,
|
||||
// 外壳字段全被丢弃 ⇒ 91 帧全是空 content。
|
||||
//
|
||||
// ⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃),
|
||||
//
|
||||
// 因此判别帧类型只能靠内容特征。
|
||||
func TestTraeSoloSSEToUnifiedChunk(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)
|
||||
}
|
||||
|
||||
// ① 正文帧 → content(**扁平**,不是 choices[].delta)
|
||||
out, err := c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"response":"Hello","reasoning_content":null,"tool_calls":null}`)
|
||||
if err != nil {
|
||||
t.Fatalf("transform: %v", err)
|
||||
}
|
||||
var ck map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("产出不是 JSON: %q", out)
|
||||
}
|
||||
if got, _ := ck["content"].(string); got != "Hello" {
|
||||
t.Fatalf("★ 正文必须摊平到顶层 content,实得 %q(原始产出 %s)\n"+
|
||||
" 返回 {choices:[{delta:...}]} 外壳会被内核丢弃", got, out)
|
||||
}
|
||||
if _, has := ck["choices"]; has {
|
||||
t.Fatalf("★ 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析,外壳会被忽略: %s", out)
|
||||
}
|
||||
|
||||
// ② 推理帧 → reasoning_content
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"response":"","reasoning_content":"We","tool_calls":null}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("推理帧产出不是 JSON: %q", out)
|
||||
}
|
||||
if got, _ := ck["reasoning_content"].(string); got != "We" {
|
||||
t.Fatalf("★ 推理必须进 reasoning_content,实得 %q(%s)", got, out)
|
||||
}
|
||||
|
||||
// ③ done 帧
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", `{"finish_reason":"stop"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("done 帧产出不是 JSON: %q", out)
|
||||
}
|
||||
if ck["done"] != true {
|
||||
t.Fatalf("★ done 帧必须 done=true,实得 %s", out)
|
||||
}
|
||||
if ck["finish_reason"] != "stop" {
|
||||
t.Fatalf("finish_reason 应为 stop,实得 %s", out)
|
||||
}
|
||||
|
||||
// ④ usage 帧
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("usage 帧产出不是 JSON: %q", out)
|
||||
}
|
||||
u, _ := ck["usage"].(map[string]interface{})
|
||||
if u == nil || u["total"] != 15.0 {
|
||||
t.Fatalf("★ usage 必须摊平为 {prompt,completion,total},实得 %s", out)
|
||||
}
|
||||
|
||||
// ⑤ 工具调用:function_call → function 归一化
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"response":"","reasoning_content":null,"tool_calls":[{"function_call":{"name":"f","arguments":"{}"}}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "function_call") {
|
||||
t.Fatalf("★ SOLO 的 function_call 必须归一化为 function,实得 %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"function"`) {
|
||||
t.Fatalf("工具调用缺 function 字段: %s", out)
|
||||
}
|
||||
t.Logf("✓ SOLO SSE 全部摊平正确")
|
||||
}
|
||||
|
||||
// 配对判据:无关帧不得产出垃圾。
|
||||
func TestTraeSoloSSEDropsUnknownFrames(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)
|
||||
}
|
||||
// metadata 帧(上游确实会发,且无正文)
|
||||
out, err := c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"model":"","session_id":"abc","prompt_completion_id":0}`)
|
||||
if err != nil {
|
||||
t.Fatalf("metadata 帧不该报错: %v", err)
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("无正文的 metadata 帧应产出空串(不制造垃圾帧),实得 %q", out)
|
||||
}
|
||||
}
|
||||
275
internal/core/channels_trae_test.go
Normal file
275
internal/core/channels_trae_test.go
Normal file
@ -0,0 +1,275 @@
|
||||
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)
|
||||
exchBody string // 非空时覆盖夹具的响应体(供形状判据用)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// ★★ 必须是**真实上游的形状**:凭据在 `Result` 里,不在顶层。
|
||||
//
|
||||
// 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken):
|
||||
// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"...",
|
||||
// "TokenExpireAt":...,"RefreshExpireAt":...}}
|
||||
//
|
||||
// ⚠️ 这条夹具原先返回**扁平** `{"Token":...}` —— 于是「代码只读顶层」
|
||||
// 这个真缺陷被夹具**完美掩盖**:所有 trae 判据都绿,真实登录却报
|
||||
// 「响应缺少 Token」。夹具形状与生产不一致 = 判据必然虚假通过。
|
||||
if h.exchBody != "" {
|
||||
fmt.Fprint(w, h.exchBody)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"ResponseMetadata":{"RequestId":"r%d"},`+
|
||||
`"Result":{"ClientID":"en1oxy7wnw8j9n","Token":"ACCESS-%d",`+
|
||||
`"RefreshToken":"RT-NEW-%d","TokenExpireAt":1800000000000,`+
|
||||
`"TokenExpireDuration":1209600000,"RefreshExpireAt":1807755929432}}`, n, 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)
|
||||
}
|
||||
411
internal/core/channels_zcode_identity_test.go
Normal file
411
internal/core/channels_zcode_identity_test.go
Normal file
@ -0,0 +1,411 @@
|
||||
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,但模型
|
||||
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
|
||||
//
|
||||
// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有
|
||||
//
|
||||
// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得
|
||||
// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。
|
||||
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)
|
||||
}
|
||||
// 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 anthropic
|
||||
res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{
|
||||
"adapters_dir": filepath.Join(dir, "adapters-no-base"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
zc, ok := j["zcode_reinstall"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("安装探针应回报 zcode_reinstall 结果,实得 %v", j)
|
||||
}
|
||||
if zc["ok"] == true {
|
||||
t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc)
|
||||
}
|
||||
msg, _ := zc["err"].(string)
|
||||
if !strings.Contains(msg, "anthropic") {
|
||||
t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg)
|
||||
}
|
||||
t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg)
|
||||
}
|
||||
|
||||
// ★ 日期块必须**幂等**:已以 <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"
|
||||
}
|
||||
]
|
||||
97
internal/core/codearts_sign_test.go
Normal file
97
internal/core/codearts_sign_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CodeArts 的华为云 SDK-HMAC-SHA256 签名必须与参考**逐字节一致**,否则服务端
|
||||
// 返回 `verify ak sk signature fail`,而这个平台的领取就永远失败 —— 且失败原因
|
||||
// 会被误读成"账号问题"。此前插件里的实现是凭印象编的另一套形状
|
||||
// (Credential={ak}/{ts}/{nonce}/sdk_request),必然验签失败。
|
||||
//
|
||||
// 期望值由参考实现(sign.ts:signRequestHuawei,Node + node:crypto)生成:
|
||||
//
|
||||
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
|
||||
//
|
||||
// 判据覆盖:canonical request 的拼装、string-to-sign 格式、最终 signature、
|
||||
// 以及参与签名的头列表(顺序与内容)。
|
||||
func TestCodeArtsSignatureMatchesReference(t *testing.T) {
|
||||
raw, err := os.ReadFile("codearts_sig_expected.json")
|
||||
if err != nil {
|
||||
t.Skipf("缺差分数据(跳过): %v", err)
|
||||
}
|
||||
var cases []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body string `json:"body"`
|
||||
AK string `json:"ak"`
|
||||
SK string `json:"sk"`
|
||||
ST string `json:"st"`
|
||||
Canonical string `json:"canonical"`
|
||||
StringToSign string `json:"string_to_sign"`
|
||||
Signature string `json:"signature"`
|
||||
SignedHeaders string `json:"signed_headers"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases) == 0 {
|
||||
t.Fatal("差分数据为空")
|
||||
}
|
||||
|
||||
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 i, tc := range cases {
|
||||
got, err := ps.Call("channels", "__codearts_sign_probe", map[string]interface{}{
|
||||
"method": tc.Method, "url": tc.URL, "body": tc.Body,
|
||||
"ak": tc.AK, "sk": tc.SK, "st": tc.ST, "date": "20261013T150405Z",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("case %d probe: %v", i, err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("case %d 无 json: %#v", i, got)
|
||||
}
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("case %d 探针报错: %v", i, m["error"])
|
||||
}
|
||||
if s, _ := m["canonical"].(string); s != tc.Canonical {
|
||||
t.Errorf("case %d canonical 不一致\n 实得: %q\n 参考: %q", i, s, tc.Canonical)
|
||||
}
|
||||
if s, _ := m["string_to_sign"].(string); s != tc.StringToSign {
|
||||
t.Errorf("case %d string-to-sign 不一致\n 实得: %q\n 参考: %q", i, s, tc.StringToSign)
|
||||
}
|
||||
if s, _ := m["signature"].(string); s != tc.Signature {
|
||||
t.Errorf("case %d signature 不一致: 实得 %s 参考 %s", i, s, tc.Signature)
|
||||
}
|
||||
if s, _ := m["signed_headers"].(string); s != tc.SignedHeaders {
|
||||
t.Errorf("case %d SignedHeaders 不一致: 实得 %q 参考 %q", i, s, tc.SignedHeaders)
|
||||
}
|
||||
// Authorization 头必须是 `SDK-HMAC-SHA256 Access=...`(不是 Credential=)
|
||||
if s, _ := m["authorization"].(string); len(s) == 0 ||
|
||||
s[:len("SDK-HMAC-SHA256 Access=")] != "SDK-HMAC-SHA256 Access=" {
|
||||
t.Errorf("case %d Authorization 形状应为 SDK-HMAC-SHA256 Access=…,实得 %q", i, s)
|
||||
}
|
||||
// 签名后追加的头绝不能进 canonical
|
||||
if s, _ := m["canonical"].(string); len(s) > 0 {
|
||||
for _, bad := range []string{"Agent-Type", "X-Language", "agent-type", "x-language"} {
|
||||
if strings.Contains(s, bad) {
|
||||
t.Errorf("case %d canonical 里出现了签名后追加的头 %s(会导致验签失败)", i, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,10 +8,10 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@ -69,6 +69,11 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
// to boot the gateway because a third-party .lua has a typo would be the
|
||||
// wrong trade.
|
||||
c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir)
|
||||
// R4-c: hand plugins the kernel action surface. Injected as an interface
|
||||
// because lua must not import core (core depends on lua, not the reverse).
|
||||
// Wired BEFORE LoadDir so a plugin that calls an action during its own
|
||||
// __init/first hook already finds a working kernel rather than a nil one.
|
||||
c.plugins.SetKernelAPI(c)
|
||||
// Plugin outbound HTTP is scoped to the hosts of configured sources. Set
|
||||
// before LoadDir so a plugin that requests on its first hook already sees
|
||||
// the real allowlist rather than the deny-all placeholder.
|
||||
@ -126,6 +131,11 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
// only appeared to work because seedKeys' Save() unsealed memory as a side
|
||||
// effect. Removing the redundant saves exposed the real ordering bug.
|
||||
cfg.AttachSecretBox(c.store.SecretBox())
|
||||
// Share that SAME box with the plugin credential vault: one master.key seals
|
||||
// config.yaml, the runtime store, AND every .secrets/*.json. Set here (not at
|
||||
// NewPlugins time) because the store — and thus the box — only exists now;
|
||||
// plugins write secrets only during login flows, well after this point.
|
||||
c.plugins.SetSecretBox(c.store.SecretBox())
|
||||
hadPlaintextSecrets, err := cfg.UnsealSecrets(c.store.SecretBox())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unseal secrets: %w", err)
|
||||
@ -650,11 +660,29 @@ func (c *Core) rebuildRegistry() error {
|
||||
// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than
|
||||
// failing startup: a malformed base_url is already rejected when the provider is
|
||||
// built, and it must not take plugins down with it.
|
||||
//
|
||||
// A source may also imply a SECOND host. OpenDesign splits inference
|
||||
// (amr-link.open-design.ai) from billing (amr-api.open-design.ai), and the
|
||||
// quota plugin derives the billing host from the inference one — so the host it
|
||||
// asks for is not the host the operator configured, and the request would be
|
||||
// refused with a message that reads like a plugin defect. Adding the derived
|
||||
// host here is what makes the probe reachable.
|
||||
//
|
||||
// The grant stays scoped: only a source that IS on amr-link pulls in amr-api, so
|
||||
// a deployment with no OpenDesign source does not silently gain the entry.
|
||||
func sourceHosts(cfg *config.Config) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(cfg.Sources))
|
||||
seen := make(map[string]bool, len(cfg.Sources))
|
||||
add := func(h string) {
|
||||
if h == "" || seen[h] {
|
||||
return
|
||||
}
|
||||
seen[h] = true
|
||||
out = append(out, h)
|
||||
}
|
||||
for _, s := range cfg.Sources {
|
||||
if s.BaseURL == "" {
|
||||
continue
|
||||
@ -663,11 +691,27 @@ func sourceHosts(cfg *config.Config) []string {
|
||||
if err != nil || u.Hostname() == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, u.Hostname())
|
||||
add(u.Hostname())
|
||||
add(deriveBillingHost(u.Hostname()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deriveBillingHost returns the billing host a configured source implies, or
|
||||
// the host unchanged when it implies none.
|
||||
//
|
||||
// Kept in Go as well as in the Lua adapter because the two must agree: Go builds
|
||||
// the allowlist and Lua builds the request URL, and a disagreement shows up as a
|
||||
// refused request rather than as an error naming the mismatch. The rule is the
|
||||
// full hostname, not a prefix — `amr-link.` as a prefix would also rewrite
|
||||
// amr-link.example.com into an OpenDesign address.
|
||||
func deriveBillingHost(host string) string {
|
||||
if host == "amr-link.open-design.ai" {
|
||||
return "amr-api.open-design.ai"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules
|
||||
// against the current providers. Slot model ids are normalized to the exact
|
||||
// configured spelling (case-insensitive match), otherwise ModelFor would fall
|
||||
@ -840,6 +884,14 @@ func (c *Core) UploadAdapter(name, code string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("adapter name required")
|
||||
}
|
||||
// Validate name SAFETY before touching the filesystem (R6). A path-traversal
|
||||
// name ("../openai") must be rejected BEFORE WriteFile, or it would already
|
||||
// have escaped the adapter dir by the time LoadAdapter re-checks. Built-in
|
||||
// names are allowed here: an admin uploading openai.lua is the documented
|
||||
// override, same as placing the file in adapter_dir by hand.
|
||||
if err := lua.ValidateAdapterName(name, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(c.cfg.AdapterDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -851,6 +903,13 @@ func (c *Core) UploadAdapter(name, code string) error {
|
||||
}
|
||||
|
||||
func (c *Core) RemoveAdapter(name string) error {
|
||||
// Validate the name so a traversal name cannot os.Remove a file outside the
|
||||
// adapter dir. A reserved (built-in) name is a legitimate delete target in
|
||||
// the running VM, so reserved-name rejection is NOT applied here — only the
|
||||
// path-safety character set is.
|
||||
if err := lua.ValidateAdapterName(name, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(c.cfg.AdapterDir, name+".lua")
|
||||
_ = os.Remove(path)
|
||||
c.vm.RemoveAdapter(name)
|
||||
@ -948,6 +1007,13 @@ func normalizeSource(s *config.Source) error {
|
||||
if s.Name == "" || s.BaseURL == "" {
|
||||
return fmt.Errorf("source requires name and base_url")
|
||||
}
|
||||
// ★ 契约:源名只允许 [A-Za-z0-9_-](见 config.ValidateSourceName)。
|
||||
// 这里对**新增/修改的源硬拒**(API 400),而不是像启动时那样只告警 ——
|
||||
// 带 `:` 或 `/` 的源名会让 `源名:模型名` 这个限定名产生两种都自圆其说的
|
||||
// 切法,同一个请求串在不同部署下可能路由到不同源,且**不会报错**。
|
||||
if err := config.ValidateSourceName(s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s.Models) == 0 {
|
||||
return fmt.Errorf("source requires at least one model")
|
||||
}
|
||||
|
||||
180
internal/core/device_harness_test.go
Normal file
180
internal/core/device_harness_test.go
Normal file
@ -0,0 +1,180 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// readBundledChannels 读内置 channels 插件源(供行为/变异测试共用)。
|
||||
func readBundledChannels(t *testing.T) (string, error) {
|
||||
t.Helper()
|
||||
return lua.ReadBundledPlugin("channels")
|
||||
}
|
||||
|
||||
// deviceHarness 是 device-poll 决策表的可复用测试台:假上游(脚本化逐次响应)
|
||||
// + channels 插件加载指定源码 + minimax 端点指到假上游。
|
||||
// 行为测试(TestChannelsDevicePollBehavior)与变异测试共用它 —— 决策表只有
|
||||
// 一份,两处不会漂移。
|
||||
type deviceHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
step int32
|
||||
// script 持续被 HTTP handler 原子读取
|
||||
script atomic.Value // []scriptStep
|
||||
}
|
||||
|
||||
type scriptStep struct {
|
||||
http int
|
||||
body string
|
||||
}
|
||||
|
||||
func newDeviceHarness(t *testing.T, src string) *deviceHarness {
|
||||
t.Helper()
|
||||
h := &deviceHarness{t: t}
|
||||
h.script.Store([]scriptStep{})
|
||||
|
||||
h.ts = 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":"DEV1","user_code":"ABCD-EFGH","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
|
||||
case "/oauth2/token":
|
||||
i := int(atomic.AddInt32(&h.step, 1)) - 1
|
||||
script := h.script.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].http != 0 && script[i].http != 200 {
|
||||
w.WriteHeader(script[i].http)
|
||||
}
|
||||
fmt.Fprint(w, script[i].body)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatalf("加载: %v", err)
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "minimax",
|
||||
"device": map[string]interface{}{
|
||||
"code_url": h.ts.URL + "/oauth2/device/code",
|
||||
"token_url": h.ts.URL + "/oauth2/token",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// setScript 设置 token 端点的逐次响应脚本。`400 {...}` 形式表示非 200。
|
||||
func (h *deviceHarness) setScript(steps ...string) {
|
||||
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:]
|
||||
}
|
||||
out = append(out, scriptStep{http: code, body: b})
|
||||
}
|
||||
h.script.Store(out)
|
||||
atomic.StoreInt32(&h.step, 0)
|
||||
}
|
||||
|
||||
func (h *deviceHarness) begin() map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"minimax"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
func (h *deviceHarness) 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{})
|
||||
}
|
||||
|
||||
// runDecisionTable 跑完整 8 行决策表,全部符合预期返回 true。
|
||||
// 这是行为契约的单一事实来源:行为测试用它断言通过;变异测试用它杀变异体。
|
||||
func (h *deviceHarness) runDecisionTable() bool {
|
||||
ok := func(cond bool) bool { return cond }
|
||||
st := func(steps ...string) map[string]interface{} {
|
||||
h.setScript(steps...)
|
||||
j := h.begin()
|
||||
sid := j["session"].(map[string]interface{})["id"].(string)
|
||||
return h.poll(sid)
|
||||
}
|
||||
|
||||
// 行1: 200+pending → 等
|
||||
if j := st(`{"status":"pending"}`); j["state"] != "pending" {
|
||||
return false
|
||||
}
|
||||
// 行2: 200+slow_down → 等 + interval+5
|
||||
h.setScript(`{"status":"slow_down"}`)
|
||||
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 < 6 {
|
||||
return false
|
||||
}
|
||||
// 行3: 200+denied → failed,message 区分于通用错误(杀"denied 不识别"变异体)
|
||||
if j := st(`{"status":"denied"}`); j["state"] != "failed" {
|
||||
return false
|
||||
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "用户拒绝") {
|
||||
return false
|
||||
}
|
||||
// 行4: 200+expired_token → failed,message 带过期字样
|
||||
if j := st(`{"status":"expired_token"}`); j["state"] != "failed" {
|
||||
return false
|
||||
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "过期") {
|
||||
return false
|
||||
}
|
||||
// 行5: 400+authorization_pending → 等
|
||||
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
|
||||
return false
|
||||
}
|
||||
// 行6: 403+slow_down → 等 + interval+5
|
||||
h.setScript(`403 {"error":"slow_down"}`)
|
||||
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 < 6 {
|
||||
return false
|
||||
}
|
||||
// 行7: 200+access_token → completed(需先授权 add_source)
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
h.setScript(`{"access_token":"MM-ACCESS-1","refresh_token":"MM-RF-1","nickname":"mm-user"}`)
|
||||
j = h.begin()
|
||||
sid = j["session"].(map[string]interface{})["id"].(string)
|
||||
if j2 := h.poll(sid); j2["state"] != "completed" || j2["account"] != "mm-user" {
|
||||
return false
|
||||
}
|
||||
// 行8: 其它 error → failed 带文本
|
||||
if j := st(`{"error":"server_error"}`); j["state"] != "failed" {
|
||||
return false
|
||||
}
|
||||
return ok(true)
|
||||
}
|
||||
73
internal/core/device_mutate_test.go
Normal file
73
internal/core/device_mutate_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDevicePollMutants 是 device-poll 执行器的变异测试。
|
||||
//
|
||||
// 用户要求:移植要照参考实现的行为形成测试项,且要变异验证测试真的能杀掉
|
||||
// 「行为走样」的代码。每个用例把 channels 源里的一个决策分支做语义变异,
|
||||
// 断言决策表**会失败**(变异体被杀)。变异体幸存 = 该分支没有覆盖。
|
||||
func TestDevicePollMutants(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 锚点会命中 deviceOneStep 里两处同形代码(workos 分支 + minimax 分支),
|
||||
// 全部替换:两个决策表(minimax + workos)都会杀,任一幸存即失败。
|
||||
muts := []struct {
|
||||
name string
|
||||
from string
|
||||
to string
|
||||
}{
|
||||
{
|
||||
name: "slow_down 不再加 interval",
|
||||
from: `sess.device_interval = (sess.device_interval or d.interval or 5) + step`,
|
||||
to: `sess.device_interval = (sess.device_interval or d.interval or 5)`,
|
||||
},
|
||||
{
|
||||
name: "denied 被当成继续等待",
|
||||
from: `if resp.status == 200 and (status == "denied" or status == "access_denied") then`,
|
||||
to: `if resp.status == 200 and (status == "denied_x" or status == "access_denied") then`,
|
||||
},
|
||||
{
|
||||
name: "expired_token 不被识别",
|
||||
from: `if resp.status == 200 and (status == "expired" or status == "expired_token") then`,
|
||||
to: `if resp.status == 200 and (status == "expired" or status == "expired_token_x") then`,
|
||||
},
|
||||
{
|
||||
name: "authorization_pending 被当成失败",
|
||||
from: `if ecode == "authorization_pending" then return false end`,
|
||||
to: `if ecode == "authorization_pending_x" then return false end`,
|
||||
},
|
||||
{
|
||||
name: "成功分支被禁用",
|
||||
from: `if resp.status == 200 and rec.access_token then`,
|
||||
to: `if resp.status == 200 and rec.access_token and false then`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, mu := range muts {
|
||||
t.Run(mu.name, func(t *testing.T) {
|
||||
if !strings.Contains(src, mu.from) {
|
||||
t.Fatalf("锚点丢失,先更新实现与锚点:%q", mu.from)
|
||||
}
|
||||
// 锚点在 deviceOneStep 的 workos/minimax 两个分支各有一份同形代码,
|
||||
// 全部替换:任一分支的变异体被任一决策表杀掉才算有覆盖。
|
||||
mutated := strings.ReplaceAll(src, mu.from, mu.to)
|
||||
if mutated == src {
|
||||
t.Fatal("变异未生效")
|
||||
}
|
||||
// 基准必须全过(测试自身有效),变异体必须被杀(有覆盖)
|
||||
if !newDeviceHarness(t, src).runDecisionTable() {
|
||||
t.Fatal("基准决策表未全过 —— 测试自身失效")
|
||||
}
|
||||
if newDeviceHarness(t, mutated).runDecisionTable() {
|
||||
t.Fatalf("变异体幸存(%s)—— 该分支无覆盖", mu.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
401
internal/core/kernel_actions.go
Normal file
401
internal/core/kernel_actions.go
Normal file
@ -0,0 +1,401 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 内核动作实现(R4-c)
|
||||
//
|
||||
// 把 Core 的公开动作暴露给插件 Lua,走 lua.KernelAPI 接口注入(依赖方向是
|
||||
// core → lua,所以接口定义在 lua 包、实现放在这里)。
|
||||
//
|
||||
// # 为什么是"动作面"而不是"给插件一个 Core 指针"
|
||||
//
|
||||
// 直接暴露 *Core 会让插件能碰任何未导出的细节,而且每一次内核重构都会变成
|
||||
// 对插件作者的破坏性变更。动作面是一份**显式的契约**:每个动作有名字、危险级、
|
||||
// 超时预算,加动作是一次有意的行为,而不是顺手多导出一个方法。
|
||||
//
|
||||
// # 凭据边界
|
||||
//
|
||||
// 这里**不实现** list_keys / find_key。Core.ListKeys() 返回的是**明文**网关
|
||||
// 密钥,把它交给第三方 Lua 等于把部署的整个凭据面交出去。key 相关的读走掩码
|
||||
// 形态(create_key 的返回是唯一一次明文,且是"刚创建的那一个")。
|
||||
var _ lua.KernelAPI = (*Core)(nil)
|
||||
|
||||
// Actions implements lua.KernelAPI.
|
||||
//
|
||||
// The catalog lives in the lua package because permissions must be checkable even
|
||||
// when no Core is wired. This method exists so the interface is satisfied by the
|
||||
// real kernel rather than by a separate adapter type.
|
||||
func (c *Core) Actions() []lua.KernelAction { return lua.KernelActions() }
|
||||
|
||||
// Invoke implements lua.KernelAPI. It dispatches one action.
|
||||
//
|
||||
// Every action is expected to honour ctx. The caller (lua.callKernel) gives up on
|
||||
// a deadline and returns an error to the plugin, but a kernel action that keeps
|
||||
// running afterwards would be an invisible leak, so the long ones (probe_all)
|
||||
// pass ctx down into the network layer.
|
||||
func (c *Core) Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error) {
|
||||
switch name {
|
||||
// ---- 只读 ----
|
||||
case "status":
|
||||
return c.actionStatus(), nil
|
||||
case "list_sources":
|
||||
return c.actionListSources(), nil
|
||||
case "list_models":
|
||||
return c.actionListModels(), nil
|
||||
case "list_adapters":
|
||||
return c.actionListAdapters(), nil
|
||||
case "list_auto_rules":
|
||||
return c.actionListAutoRules(), nil
|
||||
case "adapter_owner":
|
||||
return c.actionAdapterOwner(args)
|
||||
|
||||
// ---- 写 ----
|
||||
case "reload":
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true}, c.Reload()
|
||||
case "reset_health":
|
||||
// ResetHealth already no-ops on a nil registry (it ranges over
|
||||
// Providers()), so this needs no guard beyond the nil check inside.
|
||||
c.ResetHealth()
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
case "probe_all":
|
||||
r := c.Registry()
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("the registry is not built yet")
|
||||
}
|
||||
r.ProbeAll(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("probe_all was cut short: %w", err)
|
||||
}
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
case "save_auto_rules":
|
||||
return c.actionSaveAutoRules(args)
|
||||
case "save_key_auto":
|
||||
return c.actionSaveKeyAuto(args)
|
||||
case "add_source":
|
||||
return c.actionAddSource(args)
|
||||
case "create_key":
|
||||
return c.actionCreateKey(args)
|
||||
|
||||
// ---- 破坏性 ----
|
||||
case "remove_source":
|
||||
return c.actionRemoveSource(args)
|
||||
case "delete_key":
|
||||
return c.actionDeleteKey(args)
|
||||
case "update_key":
|
||||
return c.actionUpdateKey(args)
|
||||
case "remove_adapter":
|
||||
return c.actionRemoveAdapter(args)
|
||||
case "set_plugin_enabled":
|
||||
return c.actionSetPluginEnabled(args)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown kernel action %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 只读实现 ----
|
||||
|
||||
func (c *Core) actionStatus() interface{} {
|
||||
// A plugin can call this DURING LoadDir (its own __init or first hook), which
|
||||
// runs before rebuildRegistry builds the registry. Returning an empty status
|
||||
// rather than panicking keeps a load-time call from taking the process down;
|
||||
// the plugin sees an empty list and the next call gives real data.
|
||||
out := map[string]interface{}{
|
||||
"sources": []interface{}{},
|
||||
"auto_slots": []interface{}{},
|
||||
"default": c.cfg.DefaultModel,
|
||||
"ready": false,
|
||||
}
|
||||
if r := c.Registry(); r != nil {
|
||||
out["sources"] = r.Status()
|
||||
out["ready"] = true
|
||||
}
|
||||
if slots := c.AutoSlotStates(); slots != nil {
|
||||
out["auto_slots"] = slots
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actionListSources returns the sources with api_key MASKED.
|
||||
//
|
||||
// Masking is not optional: a plugin has no legitimate need for another source's
|
||||
// key, and a plugin that wrote the source itself already has it in its own
|
||||
// credential vault.
|
||||
func (c *Core) actionListSources() interface{} {
|
||||
srcs := c.Sources()
|
||||
out := make([]map[string]interface{}, 0, len(srcs))
|
||||
for _, s := range srcs {
|
||||
models := make([]string, 0, len(s.Models))
|
||||
for _, m := range s.Models {
|
||||
models = append(models, m.ID)
|
||||
}
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"base_url": s.BaseURL,
|
||||
"adapter": s.Adapter,
|
||||
"models": models,
|
||||
"has_key": s.APIKey != "" || s.APIKeyEnv != "",
|
||||
"key_hint": maskSecretHint(s.APIKey),
|
||||
"max_concurrent": s.MaxConcurrent,
|
||||
// rpm must be exposed too: it carries the per-model minimum gap, and
|
||||
// max_concurrent alone cannot distinguish "serial WITH a gap" from
|
||||
// "serial with no gap" — the latter still collides with the
|
||||
// upstream's per-model concurrency window.
|
||||
"rpm": s.RPM,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// maskSecretHint shows only a short suffix so an operator (and a plugin) can tell
|
||||
// two keys apart without either getting the secret.
|
||||
func maskSecretHint(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if len(k) <= 4 {
|
||||
return "***"
|
||||
}
|
||||
return "***" + k[len(k)-4:]
|
||||
}
|
||||
|
||||
func (c *Core) actionListModels() interface{} {
|
||||
if r := c.Registry(); r != nil {
|
||||
return r.ModelList()
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (c *Core) actionListAdapters() interface{} {
|
||||
return c.ListAdapters()
|
||||
}
|
||||
|
||||
func (c *Core) actionListAutoRules() interface{} {
|
||||
return map[string]interface{}{
|
||||
"auto": c.AutoRules(),
|
||||
"auto_image": c.AutoImageRules(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Core) actionAdapterOwner(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("adapter_owner requires a name")
|
||||
}
|
||||
owner := ""
|
||||
if ps := c.Plugins(); ps != nil {
|
||||
owner = ps.AdapterOwnerOf(name)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": name,
|
||||
"owner": owner, // "" means operator-installed
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- 写实现 ----
|
||||
|
||||
func (c *Core) actionSaveAutoRules(args map[string]interface{}) (interface{}, error) {
|
||||
entries, err := decodeScopes(args["entries"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.SaveAutoRules(entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionSaveKeyAuto(args map[string]interface{}) (interface{}, error) {
|
||||
keyName, _ := args["key_name"].(string)
|
||||
if keyName == "" {
|
||||
return nil, fmt.Errorf("save_key_auto requires key_name")
|
||||
}
|
||||
entries, err := decodeScopes(args["entries"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.SaveKeyAuto(keyName, entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "count": len(entries)}, nil
|
||||
}
|
||||
|
||||
// actionAddSource is the capability that makes "plugin-driven upstream" real: a
|
||||
// plugin that has just obtained credentials can wire its endpoint in as a source
|
||||
// without the operator editing config.yaml.
|
||||
func (c *Core) actionAddSource(args map[string]interface{}) (interface{}, error) {
|
||||
raw, ok := args["source"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("add_source requires a source table")
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source is not encodable: %w", err)
|
||||
}
|
||||
var src config.Source
|
||||
if err := json.Unmarshal(b, &src); err != nil {
|
||||
return nil, fmt.Errorf("source shape is invalid: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(src.Name) == "" {
|
||||
return nil, fmt.Errorf("source requires a name")
|
||||
}
|
||||
if strings.TrimSpace(src.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("source %s requires a base_url", src.Name)
|
||||
}
|
||||
if err := c.AddSource(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": src.Name}, nil
|
||||
}
|
||||
|
||||
// actionCreateKey creates a gateway key.
|
||||
//
|
||||
// The plaintext key is returned ONCE, in this response — the same posture as the
|
||||
// admin API. It is not retrievable later, so a plugin that needs it must store it
|
||||
// in its own credential vault immediately.
|
||||
func (c *Core) actionCreateKey(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
role, _ := args["role"].(string)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
models, err := decodeScopes(args["models"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err := c.CreateKey(name, role, models, "created by plugin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"key": k.Key,
|
||||
"name": k.Name,
|
||||
"role": k.Role,
|
||||
"models": k.Models,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- 破坏性实现 ----
|
||||
|
||||
func (c *Core) actionRemoveSource(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("remove_source requires a name")
|
||||
}
|
||||
// The last source is not removable: an empty registry turns every request
|
||||
// into a 503, and the operator would have to fix it from the filesystem.
|
||||
if len(c.Sources()) <= 1 {
|
||||
return nil, fmt.Errorf("refusing to remove the last source: the gateway would have nothing to route to")
|
||||
}
|
||||
if err := c.RemoveSource(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": name}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionDeleteKey(args map[string]interface{}) (interface{}, error) {
|
||||
key, _ := args["key"].(string)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("delete_key requires the key to delete")
|
||||
}
|
||||
ok, err := c.DeleteKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no such key")
|
||||
}
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionUpdateKey(args map[string]interface{}) (interface{}, error) {
|
||||
key, _ := args["key"].(string)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("update_key requires the key")
|
||||
}
|
||||
name, _ := args["name"].(string)
|
||||
role, _ := args["role"].(string)
|
||||
models, err := decodeScopes(args["models"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err := c.UpdateKey(key, name, role, models, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": k.Name, "role": k.Role}, nil
|
||||
}
|
||||
|
||||
// actionRemoveAdapter deletes an adapter, but only one the CALLER plugin owns —
|
||||
// the ownership check lives in Plugins.RemoveAdapter (see the adapter bridge).
|
||||
// A plugin can always remove its own; removing an operator's or a built-in is
|
||||
// refused there, and that refusal is the security property.
|
||||
func (c *Core) actionRemoveAdapter(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("remove_adapter requires a name")
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if ps == nil {
|
||||
return nil, fmt.Errorf("no plugins loaded")
|
||||
}
|
||||
plugin, _ := args["plugin"].(string)
|
||||
if plugin == "" {
|
||||
return nil, fmt.Errorf("remove_adapter needs the calling plugin's name")
|
||||
}
|
||||
if err := ps.RemoveAdapter(plugin, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": name}, nil
|
||||
}
|
||||
|
||||
func (c *Core) actionSetPluginEnabled(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("set_plugin_enabled requires a name")
|
||||
}
|
||||
enabled, ok := args["enabled"].(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("set_plugin_enabled requires enabled (boolean)")
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if ps == nil {
|
||||
return nil, fmt.Errorf("no plugins loaded")
|
||||
}
|
||||
if err := ps.SetEnabled(name, enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "name": name, "enabled": enabled}, nil
|
||||
}
|
||||
|
||||
// decodeScopes converts a Lua-decoded array of model-scope tables into
|
||||
// []config.ModelScope. It accepts both an explicit array and a single table so a
|
||||
// one-entry chain does not need a wrapper.
|
||||
func decodeScopes(v interface{}) ([]config.ModelScope, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entries are not encodable: %w", err)
|
||||
}
|
||||
var list []config.ModelScope
|
||||
if err := json.Unmarshal(b, &list); err == nil {
|
||||
return list, nil
|
||||
}
|
||||
var one config.ModelScope
|
||||
if err := json.Unmarshal(b, &one); err != nil {
|
||||
return nil, fmt.Errorf("entries shape is invalid: %w", err)
|
||||
}
|
||||
return []config.ModelScope{one}, nil
|
||||
}
|
||||
373
internal/core/kernel_actions_test.go
Normal file
373
internal/core/kernel_actions_test.go
Normal file
@ -0,0 +1,373 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 本文件验证 R4-c/R4-d 的闭环:**插件自己驱动外部源的链接认证,然后把源接进内核**。
|
||||
//
|
||||
// 这是用户点明的"同类插件"能力(对照 dsh-our-free-model):插件主导 OAuth 登录、
|
||||
// 存凭据、定时领额度、把拿到的端点注册成内核上游。此前 llmsproxy 的插件只能管
|
||||
// 自己的适配器,够不到内核。
|
||||
|
||||
// kernelTestCore 起一个最小 core(一个源 + 一个 key)。
|
||||
func kernelTestCore(t *testing.T) *Core {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
// 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
|
||||
// LLMSPROXY_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
|
||||
// /etc/llmsproxy/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
|
||||
// 把 26 个测试账号写进了生产 .channels-store.json)。
|
||||
// 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
}
|
||||
// ★ 显示器号与端口用**高段号**,避开生产。
|
||||
//
|
||||
// 真实事故(2026-10-15):生产 unit 带 `PrivateTmp=yes`,它起的 Xvfb :91 的
|
||||
// 锁文件在**服务自己的 /tmp** 里 —— 宿主上的测试看不到它,于是判"91 空闲"
|
||||
// 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着
|
||||
// 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。
|
||||
// 故测试整体挪到 300+。
|
||||
if os.Getenv("LLMSPROXY_XDISPLAY_BASE") == "" {
|
||||
t.Setenv("LLMSPROXY_XDISPLAY_BASE", "300")
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
PluginDir: pluginDir,
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-kernel-0001"},
|
||||
Sources: []config.Source{{
|
||||
Name: "primary", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
APIKey: "secret-primary-key", MaxConcurrent: 1,
|
||||
Models: []config.Model{{ID: "m1", Kind: "chat"}},
|
||||
}},
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 测试结束**必须**把插件拉起的服务端浏览器栈收掉。
|
||||
// 为何必需:callback 流(trae/gemini/有道)在 start_login 时就起一整套
|
||||
// Xvfb+chromium+x11vnc+websockify,而绝大多数测试不会显式 stop_browser、
|
||||
// 会话 TTL 也还没到 —— 于是一轮 core 全包跑完就攒出 7 套常驻进程
|
||||
// (实测 websockify×7 + x11vnc×7 + 7 个 profile 目录),把显示器号与端口
|
||||
// 段逐步耗光,后续测试报「chromium/websockify 端口未就绪」。
|
||||
// 在这一处统一收掉,比让每个测试各自记得更可靠。
|
||||
t.Cleanup(func() {
|
||||
ps := c.Plugins()
|
||||
if ps == nil {
|
||||
return
|
||||
}
|
||||
for _, name := range []string{"channels"} {
|
||||
_, _ = ps.InvokeHandler(name, "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
})
|
||||
}
|
||||
})
|
||||
t.Cleanup(c.Close)
|
||||
return c
|
||||
}
|
||||
|
||||
// grant 给插件授权若干动作(模拟运维在设置页点选)。
|
||||
func grant(t *testing.T, c *Core, plugin string, actions ...string) {
|
||||
t.Helper()
|
||||
if err := c.Plugins().SetPluginKernelPermission(plugin, actions, false); err != nil {
|
||||
t.Fatalf("grant %v to %s: %v", actions, plugin, err)
|
||||
}
|
||||
}
|
||||
|
||||
const kernelProbePlugin = `
|
||||
local plugin = { name = "kernelprobe", version = "1.0" }
|
||||
plugin.exports = { "read_status", "read_models", "read_sources", "try_delete_key", "probe_count", "call_reload" }
|
||||
plugin.kernel = { "status", "list_models", "reload" }
|
||||
|
||||
function plugin.read_status()
|
||||
local st, err = __llmsproxy_kernel.call("status", {})
|
||||
if err then return { err = err } end
|
||||
return { ready = st.ready, nsources = #(st.sources or {}) }
|
||||
end
|
||||
|
||||
function plugin.read_models()
|
||||
local m, err = __llmsproxy_kernel.call("list_models", {})
|
||||
if err then return { err = err } end
|
||||
return { models = m }
|
||||
end
|
||||
|
||||
-- sources 动作返回的密钥必须是掩码的
|
||||
function plugin.read_sources()
|
||||
local s, err = __llmsproxy_kernel.call("list_sources", {})
|
||||
if err then return { err = err } end
|
||||
return { sources = s }
|
||||
end
|
||||
|
||||
-- 未授权的破坏性动作必须被拒
|
||||
function plugin.try_delete_key()
|
||||
local _, err = __llmsproxy_kernel.call("delete_key", { key = "sk-kernel-0001" })
|
||||
return { err = err }
|
||||
end
|
||||
|
||||
-- 授权数量可见(插件据此优雅降级)
|
||||
function plugin.probe_count()
|
||||
local acts = __llmsproxy_kernel.actions()
|
||||
local granted = 0
|
||||
for _, a in ipairs(acts) do
|
||||
if a.granted then granted = granted + 1 end
|
||||
end
|
||||
return { total = #acts, granted = granted }
|
||||
end
|
||||
|
||||
function plugin.call_reload()
|
||||
local r, err = __llmsproxy_kernel.call("reload", {})
|
||||
return { ok = r and r.ok, err = err }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
|
||||
// TestPluginCallsKernelActions 验证插件能调内核动作,且权限边界成立。
|
||||
func TestPluginCallsKernelActions(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "status", "list_models", "list_sources")
|
||||
|
||||
// 只读动作可用
|
||||
out, err := c.Plugins().Call("kernelprobe", "read_status", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("read_status: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["err"] != nil {
|
||||
t.Fatalf("status 调用失败: %v", m["err"])
|
||||
}
|
||||
if m["ready"] != true {
|
||||
t.Errorf("registry 应已就绪: %#v", m)
|
||||
}
|
||||
if n, _ := m["nsources"].(float64); n < 1 {
|
||||
t.Errorf("应至少有 1 个源, 实得 %v", m["nsources"])
|
||||
}
|
||||
|
||||
// 模型列表
|
||||
out, err = c.Plugins().Call("kernelprobe", "read_models", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if models, ok := out.(map[string]interface{})["models"].([]interface{}); !ok || len(models) == 0 {
|
||||
t.Errorf("模型列表应为非空数组: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionMasksSourceKeys 是安全断言:list_sources **不得**泄漏 api_key。
|
||||
func TestKernelActionMasksSourceKeys(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "list_sources")
|
||||
|
||||
out, err := c.Plugins().Call("kernelprobe", "read_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把整个返回值序列化后检查:明文密钥绝不能出现在里面
|
||||
b, _ := json.Marshal(out)
|
||||
if s := string(b); json.Valid(b) && contains(s, "secret-primary-key") {
|
||||
t.Fatalf("list_sources 泄漏了源的明文 api_key: %s", s)
|
||||
}
|
||||
// 但应有可区分性(has_key + 掩码后缀)
|
||||
if !contains(string(b), "***") {
|
||||
t.Errorf("应给出掩码提示而不是什么都没有: %s", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionDeniedWithoutGrant 是权限默认拒绝的断言。
|
||||
func TestKernelActionDeniedWithoutGrant(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 故意不授权任何动作
|
||||
out, err := c.Plugins().Call("kernelprobe", "try_delete_key", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("调用本身不该报错(拒绝应作为返回值): %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["err"] == nil || m["err"] == "" {
|
||||
t.Fatal("未授权的 delete_key 必须被拒")
|
||||
}
|
||||
t.Logf("拒绝信息: %v", m["err"])
|
||||
// 拒绝信息要点名动作与插件(否则运维无法行动)
|
||||
msg, _ := m["err"].(string)
|
||||
if !contains(msg, "delete_key") || !contains(msg, "kernelprobe") {
|
||||
t.Errorf("拒绝信息应点名动作与插件, 实得 %q", msg)
|
||||
}
|
||||
// 且密钥必须还在
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); !ok {
|
||||
t.Fatal("被拒的动作不应真的执行")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionsReportGranted 插件能看到自己拿到了哪些权限。
|
||||
func TestKernelActionsReportGranted(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "status", "list_models")
|
||||
|
||||
out, err := c.Plugins().Call("kernelprobe", "probe_count", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
total, _ := m["total"].(float64)
|
||||
granted, _ := m["granted"].(float64)
|
||||
if total < 15 {
|
||||
t.Errorf("动作总数应 >=15, 实得 %v", total)
|
||||
}
|
||||
if granted != 2 {
|
||||
t.Errorf("授权数应为 2(status+list_models), 实得 %v", granted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginRegistersSourceIntoKernel 是"同类插件"的核心闭环:
|
||||
// 插件通过内核动作把一个新的外部源接进网关。
|
||||
func TestPluginRegistersSourceIntoKernel(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
code := `
|
||||
local plugin = { name = "upstream", version = "1.0" }
|
||||
plugin.exports = { "add_my_source" }
|
||||
plugin.kernel = { "add_source", "list_sources" }
|
||||
|
||||
function plugin.add_my_source(args)
|
||||
local src = {
|
||||
name = "plugin-provided",
|
||||
base_url = "http://127.0.0.1:9/v1",
|
||||
adapter = "openai",
|
||||
api_key = "plugin-managed-token",
|
||||
max_concurrent = 2,
|
||||
models = { { id = "plugin-model", kind = "chat", priority = 10 } },
|
||||
}
|
||||
local r, err = __llmsproxy_kernel.call("add_source", { source = src })
|
||||
if err then return { err = err } end
|
||||
return { ok = r.ok, name = r.name }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("upstream", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "upstream", "add_source", "list_sources")
|
||||
|
||||
out, err := c.Plugins().Call("upstream", "add_my_source", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("add_source: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["err"] != nil {
|
||||
t.Fatalf("插件注册源失败: %v", m["err"])
|
||||
}
|
||||
if m["name"] != "plugin-provided" {
|
||||
t.Fatalf("返回的源名不对: %#v", m)
|
||||
}
|
||||
|
||||
// 内核侧必须真的有了这个源 —— 这是"接进网关"的定义
|
||||
found := false
|
||||
for _, s := range c.Sources() {
|
||||
if s.Name == "plugin-provided" {
|
||||
found = true
|
||||
if s.BaseURL != "http://127.0.0.1:9/v1" {
|
||||
t.Errorf("base_url 未落库: %q", s.BaseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("插件注册的源没有出现在内核源列表里")
|
||||
}
|
||||
// 且模型已可路由
|
||||
for _, id := range c.Registry().ModelList() {
|
||||
if id == "plugin-model" {
|
||||
return // 通过
|
||||
}
|
||||
}
|
||||
t.Errorf("插件注册的模型未进入可路由列表: %v", c.Registry().ModelList())
|
||||
}
|
||||
|
||||
// TestKernelPermissionPersists 授权必须落盘(否则重启即失效)。
|
||||
func TestKernelPermissionPersists(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant(t, c, "kernelprobe", "status", "reload")
|
||||
|
||||
pdir := c.Config().PluginDir
|
||||
path := filepath.Join(pdir, ".kernel-perms.json")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("授权未落盘: %v", err)
|
||||
}
|
||||
if !contains(string(b), "reload") {
|
||||
t.Errorf("落盘内容缺少动作名: %s", b)
|
||||
}
|
||||
|
||||
// 模拟重启:新建 Plugins、加载授权、权限仍在
|
||||
vm2 := lua.NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm2.Stop()
|
||||
ps2 := lua.NewPlugins(vm2, pdir)
|
||||
if err := ps2.LoadKernelPermissions(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perm := ps2.PluginKernelPermission("kernelprobe")
|
||||
if len(perm.Actions) != 2 {
|
||||
t.Fatalf("重启后授权应保留 2 项, 实得 %#v", perm.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownKernelActionRejected 拼错的动作名必须在授权时就被拒,
|
||||
// 而不是静默授予"什么都不允许"。
|
||||
func TestUnknownKernelActionRejected(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
if err := c.Plugins().LoadSource("kernelprobe", kernelProbePlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Plugins().SetPluginKernelPermission("kernelprobe", []string{"no_such_action"}, false); err == nil {
|
||||
t.Fatal("未知动作名应被拒")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelActionSurfaceExcludesPlaintextKeys 是凭据边界的回归断言:
|
||||
// 动作面里绝不能出现返回明文密钥的动作(ListKeys/FindKey 被有意排除)。
|
||||
func TestKernelActionSurfaceExcludesPlaintextKeys(t *testing.T) {
|
||||
for _, a := range lua.KernelActions() {
|
||||
switch a.Name {
|
||||
case "list_keys", "find_key", "get_key":
|
||||
t.Fatalf("动作面出现了明文密钥读取动作 %q —— 这会把部署的凭据面交给插件", a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return len(needle) > 0 && len(haystack) >= len(needle) &&
|
||||
func() bool {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}()
|
||||
}
|
||||
39
internal/core/kernel_effect_test.go
Normal file
39
internal/core/kernel_effect_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestGrantedDestructiveActionTakesEffect 确认授权后的破坏性动作**真的执行**。
|
||||
//
|
||||
// 上一条测试只证明"不死锁、有返回"。一个把所有动作都变成空转的 dispatch 也能
|
||||
// 通过它 —— 所以这条单独断言副作用确实落地(密钥真的被删)。
|
||||
func TestGrantedDestructiveActionTakesEffect(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
code := `
|
||||
local plugin = { name = "eff", version = "1.0" }
|
||||
plugin.exports = { "del" }
|
||||
function plugin.del(args)
|
||||
local r, err = __llmsproxy_kernel.call("delete_key", { key = args.key })
|
||||
return { ok = r ~= nil, err = err }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("eff", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = c.Plugins().SetPluginKernelPermission("eff", []string{"delete_key"}, false)
|
||||
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); !ok {
|
||||
t.Fatal("前置条件:key 应存在")
|
||||
}
|
||||
out, err := c.Plugins().Call("eff", "del", map[string]interface{}{"key": "sk-kernel-0001"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("动作未成功: %#v", m)
|
||||
}
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); ok {
|
||||
t.Fatal("动作报告成功但副作用没发生 —— dispatch 是空转")
|
||||
}
|
||||
}
|
||||
69
internal/core/kernel_no_deadlock_test.go
Normal file
69
internal/core/kernel_no_deadlock_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestNoKernelActionDeadlocksFromLua 对**每个**内核动作做同一条断言:
|
||||
// 在插件 Lua 调用(此时持有 p.mu)内执行时不得死锁。
|
||||
//
|
||||
// 为什么是"每个"而不是"那个出问题的":最初只有 set_plugin_enabled 被发现自死锁
|
||||
// (它经 SetEnabled 重入 p.mu),但"是否还有别的动作也会重入"只能靠枚举回答。
|
||||
// 这条测试就是那次枚举的固化 —— 将来新增动作时如果也重入插件锁,它会失败。
|
||||
//
|
||||
// 同时它验证了"超时后副作用仍在幕后执行"这个更隐蔽的变体:那个缺陷的表现是
|
||||
// 插件收到 error、但动作其实生效了。断言方式只是"不死锁 + 返回",因为死锁与
|
||||
// 迟到副作用是同一个根因(在错误的锁上下文里执行)。
|
||||
func TestNoKernelActionDeadlocksFromLua(t *testing.T) {
|
||||
for _, a := range lua.KernelActions() {
|
||||
a := a
|
||||
t.Run(a.Name, func(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
code := `
|
||||
local plugin = { name = "probe_all_actions", version = "1.0" }
|
||||
plugin.exports = { "run" }
|
||||
function plugin.run(args)
|
||||
local r, err = __llmsproxy_kernel.call(args.action, args.arg or {})
|
||||
return { ok = r ~= nil, err = err }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("probe_all_actions", code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 授权所有动作(含破坏性),以便真正执行到
|
||||
all := make([]string, 0)
|
||||
for _, x := range lua.KernelActions() {
|
||||
all = append(all, x.Name)
|
||||
}
|
||||
if err := c.Plugins().SetPluginKernelPermission("probe_all_actions", all, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arg := map[string]interface{}{}
|
||||
switch a.Name {
|
||||
case "name", "adapter_owner":
|
||||
arg["name"] = "primary"
|
||||
}
|
||||
if a.Name == "adapter_owner" {
|
||||
arg = map[string]interface{}{"name": "openai"}
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, err := c.Plugins().Call("probe_all_actions", "run",
|
||||
map[string]interface{}{"action": a.Name, "arg": arg})
|
||||
if err != nil {
|
||||
t.Logf("%s 返回 err=%v", a.Name, err)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatalf("动作 %s 在 Lua 调用内死锁(deferred=%v danger=%s)", a.Name, a.Deferred, a.Danger)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
22
internal/core/mutation_guard_test.go
Normal file
22
internal/core/mutation_guard_test.go
Normal file
@ -0,0 +1,22 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 把测试进程的时区钉成 **UTC**。
|
||||
//
|
||||
// 为什么必需:Qoder 的每日活动按 **UTC+8** 结算,判定必须用算术平移而不是
|
||||
// 本机时区。但这台机器**本身就是 HKT(+0800)** —— 于是在真实环境里
|
||||
// 「os.date("!*t")(UTC 平移)」与「os.date("*t")(本机时区)」结果**完全相同**,
|
||||
// 判据无法区分二者(实测:把实现改成用本机时区,判据照样通过)。
|
||||
// 钉成 UTC 后两者相差 8 小时,误用本机时区的实现立刻暴露。
|
||||
//
|
||||
// 只影响测试进程,不改系统时区。
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv("TZ", "UTC")
|
||||
time.Local = time.UTC
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
108
internal/core/source_hosts_test.go
Normal file
108
internal/core/source_hosts_test.go
Normal file
@ -0,0 +1,108 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// The plugin outbound allowlist is built from the hosts of configured sources.
|
||||
// That rule is correct for every adapter whose credential and endpoint live on
|
||||
// the same host — which was all of them until OpenDesign.
|
||||
//
|
||||
// OpenDesign splits them: inference is amr-link.open-design.ai, billing is
|
||||
// amr-api.open-design.ai. The quota plugin derives the billing host from the
|
||||
// inference one, so the host it asks for is NOT the host the operator
|
||||
// configured. The request is then refused by the allowlist with a message that
|
||||
// reads like a plugin defect ("host ... is not an allowed outbound destination")
|
||||
// rather than a missing entry.
|
||||
//
|
||||
// This file pins the bridge: when a source looks like OpenDesign, the derived
|
||||
// billing host joins the allowlist.
|
||||
|
||||
func TestSourceHostsIncludesConfiguredHosts(t *testing.T) {
|
||||
cfg := &config.Config{Sources: []config.Source{
|
||||
{Name: "a", BaseURL: "https://api.deepseek.com"},
|
||||
{Name: "b", BaseURL: "https://amr-link.open-design.ai/v1"},
|
||||
{Name: "empty", BaseURL: ""},
|
||||
{Name: "bad", BaseURL: "://nonsense"},
|
||||
}}
|
||||
got := map[string]bool{}
|
||||
for _, h := range sourceHosts(cfg) {
|
||||
got[h] = true
|
||||
}
|
||||
for _, want := range []string{"api.deepseek.com", "amr-link.open-design.ai"} {
|
||||
if !got[want] {
|
||||
t.Errorf("sourceHosts missing configured host %q (allowlist would refuse "+
|
||||
"every plugin request to it): %v", want, got)
|
||||
}
|
||||
}
|
||||
// An empty/unparseable base_url must not abort the whole list.
|
||||
if !got["api.deepseek.com"] {
|
||||
t.Fatal("an unparseable sibling source dropped a valid host")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceHostsAddsDerivedBillingHost covers the OpenDesign case at the level
|
||||
// that actually matters: the host the PLUGIN asks for must be in the allowlist.
|
||||
func TestSourceHostsAddsDerivedBillingHost(t *testing.T) {
|
||||
cfg := &config.Config{Sources: []config.Source{
|
||||
{Name: "vela", BaseURL: "https://amr-link.open-design.ai/v1"},
|
||||
}}
|
||||
got := map[string]bool{}
|
||||
for _, h := range sourceHosts(cfg) {
|
||||
got[h] = true
|
||||
}
|
||||
if !got["amr-link.open-design.ai"] {
|
||||
t.Fatalf("inference host missing: %v", got)
|
||||
}
|
||||
if !got["amr-api.open-design.ai"] {
|
||||
t.Fatalf("the billing host the quota plugin derives is not in the allowlist, "+
|
||||
"so every OpenDesign balance probe is refused before it leaves the "+
|
||||
"process: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no OpenDesign source must not silently gain its billing
|
||||
// host: an allowlist entry is a grant, and widening it for everyone would let an
|
||||
// unrelated plugin reach a host the operator never configured.
|
||||
func TestSourceHostsDoesNotGrantUnconfiguredBillingHost(t *testing.T) {
|
||||
cfg := &config.Config{Sources: []config.Source{
|
||||
{Name: "ds", BaseURL: "https://api.deepseek.com"},
|
||||
}}
|
||||
for _, h := range sourceHosts(cfg) {
|
||||
if h == "amr-api.open-design.ai" {
|
||||
t.Fatal("the OpenDesign billing host was granted to a deployment with " +
|
||||
"no OpenDesign source")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The derivation has to agree with the Lua side. Keep the two spellings of the
|
||||
// rule in one place so a change to one is visible when reading the other.
|
||||
func TestSourceHostsDerivationMatchesPluginRule(t *testing.T) {
|
||||
// what the Lua adapter does: origin:gsub("^amr%-link%.", "amr-api.")
|
||||
const in = "amr-link.open-design.ai"
|
||||
want := "amr-api.open-design.ai"
|
||||
got := deriveBillingHost(in)
|
||||
if got != want {
|
||||
t.Fatalf("deriveBillingHost(%q) = %q, want %q — the Go allowlist and the "+
|
||||
"Lua probe must agree on the host or the request is refused", in, got, want)
|
||||
}
|
||||
// A host that is not amr-link stays untouched, so this cannot accidentally
|
||||
// rewrite an unrelated provider into an OpenDesign address.
|
||||
for _, other := range []string{"api.deepseek.com", "amr-link.example.com", "xamr-link.a"} {
|
||||
if deriveBillingHost(other) != other {
|
||||
t.Errorf("deriveBillingHost(%q) = %q, want it unchanged", other, deriveBillingHost(other))
|
||||
}
|
||||
}
|
||||
// url.Parse round-trip keeps the helper honest about what it receives.
|
||||
u, err := url.Parse("https://amr-link.open-design.ai/v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deriveBillingHost(u.Hostname()) != "amr-api.open-design.ai" {
|
||||
t.Fatal("derivation failed on a real base_url hostname")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user