chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容

按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:

1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
   本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
   仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
   - `main` 独有的**文件**:0 个;
   - `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
     main 独有行数:0;
   - `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
   - `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
   - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
     (`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
   且不会丢任何 main 独有的修复。

本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth

校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
This commit is contained in:
JianFeeeee
2026-10-14 16:52:58 +08:00
parent 0004545190
commit 9bec158d2e
94 changed files with 11409 additions and 974 deletions

View File

@ -122,14 +122,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
@ -175,9 +175,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
}
@ -207,7 +207,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})"
}
# ---------- 原子替换配置 ----------
@ -223,7 +223,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))"
}
# ---------- 配置预检 ----------
@ -236,7 +236,7 @@ verify_config() {
local out
if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then
log "✓ 配置合法"
log "OK 配置合法"
return 0
fi
@ -276,7 +276,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 解封,未替换任何文件:"
@ -292,7 +292,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 "凭据无法解密(钥匙与配置不匹配),未替换任何文件:"
@ -388,7 +388,7 @@ 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。
@ -449,14 +449,14 @@ sync_plugins() {
local name; name="$(basename "$src")"
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
done
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
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"
}
# ---------- 健康检查 ----------
@ -471,7 +471,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)"
@ -540,7 +540,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

491
docs/channels-parity.md Normal file
View File

@ -0,0 +1,491 @@
# 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 特性或前提不存在的模块。

View File

@ -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...]],
},
@ -428,7 +428,7 @@ end
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**)
都用这同一把互斥锁串行化。
⚠️ 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
注意: 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
`plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了,
对实际流量完全无效。
@ -449,7 +449,7 @@ end
与适配器相同:`json.encode` / `json.decode`、`log(level, msg)`、
`hmac_sha256_hex` / `sha256_hex` / `base64_encode` / `tohex`。
⚠️ `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
注意: `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
### 6.4 命名
@ -536,7 +536,7 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
{ "prices": { ... }, "count_failures": true }
```
### 7.5 ⚠️ 计费插件只报表,不执法
### 7.5 注意: 计费插件只报表,不执法
**网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。**
本插件不参与任何路由或配额决策。

View File

@ -17,7 +17,7 @@ import (
//
// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据)
// paste(opencode:直接 complete_login 贴 API key)
// manual(qoder:start_login 返回 manual_hint,再凭据完成)
// device-poll 授权页(qoder:本地造 PKCE,把手动授权页给用户,再凭据完成)
//
// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。
func TestChannelsFlowsE2E(t *testing.T) {
@ -40,7 +40,7 @@ func TestChannelsFlowsE2E(t *testing.T) {
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":"qoder-default","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`)
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:
@ -166,7 +166,9 @@ func TestChannelsFlowsE2E(t *testing.T) {
t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody)
}
// ---------- ③ manual(qoder):hint 明确 + 凭据完成 ----------
// ---------- ③ 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 {
@ -176,9 +178,16 @@ func TestChannelsFlowsE2E(t *testing.T) {
if st != 200 {
t.Fatalf("qoder start_login status=%d", st)
}
hint, _ := m["manual_hint"].(string)
if !strings.Contains(hint, "WASM") {
t.Fatalf("qoder manual_hint 应说明 WASM 不可自动, 实得 %q", hint)
// 真流程必须给出授权页 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"}`)
@ -189,12 +198,12 @@ func TestChannelsFlowsE2E(t *testing.T) {
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qoder-default") {
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qmodel_38max") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "qoder-default") {
t.Fatalf("qoder 接入后 /v1/models 未出现 qoder-default: %s", modelsBody)
if !strings.Contains(modelsBody, "qmodel_38max") {
t.Fatalf("qoder 接入后 /v1/models 未出现 qmodel_38max: %s", modelsBody)
}
}

View File

@ -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)
}
}

View 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
}

View 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"])
}
}

View 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)
}
}
})
}

View File

@ -0,0 +1,150 @@
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(重启就重领是错的)
if ln := lineOf("auto_claim"); strings.Contains(ln, "first_due_now") {
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
}

View 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() }

View 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)
}
}
}
}

View 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 路径可能错了")
}
}

View 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")
}
}

View 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)
}
}

View 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",
})
}

View 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")
}
}

View 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
}

View 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
}

View 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
}

View 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 与幂等键)")
}
})
}
}

View 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)
}
}

View 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
}

View File

@ -128,7 +128,7 @@ func TestLoomyWechatPollBehavior(t *testing.T) {
// 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里)
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("★ 404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
t.Fatalf("404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
}
})

View 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),而非编造的占位名")
}
}

View File

@ -92,7 +92,7 @@ func TestChannelsPluginEndToEnd(t *testing.T) {
}
// 且不得出现在 state 里
if stateHasSecret(ps, "channels", "tok-1") {
t.Fatal("★ 凭据泄漏进了 plugin.state")
t.Fatal("凭据泄漏进了 plugin.state")
}
// 5. 运维授权后,重新接入应当真的把源注册进内核

View File

@ -124,13 +124,18 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
if !ok {
t.Fatal("带凭据账号应已同步源")
}
if n := len(asArr(sm["models"])); n != 3 {
t.Fatalf("源应有3模型, 实得 %d", n)
// 不写死具体数量:兜底目录是照参考 *-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 != 2 {
t.Fatalf("关1模型后源应2模型, 实得 %d", n)
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", `{}`)
@ -260,17 +265,33 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) {
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`)
})
t.Run("model_clear_dead_只清黑名单里已下线的", func(t *testing.T) {
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
// 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 n := len(asArr(j["removed"])); n != 0 {
t.Fatalf("在册模型不算死模型, 实得 %d", n)
if j["ok"] != true {
t.Fatalf("model_clear_dead 应成功: %v", j)
}
// 手动放一个不在 models 表里的黑名单项
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["ghost-model"],"disabled":true}`)
j = invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
if n := len(asArr(j["removed"])); n != 1 {
t.Fatalf("死模型应被清: %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}`)
})

View 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"])
}
})
}
}

View 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"])
}
})
}
}

View 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"])
}
})
}

View File

@ -175,7 +175,7 @@ func TestQoderDevicePollBehavior(t *testing.T) {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok {
t.Fatalf("★ 池路径凭据未落到 channels:qoder:%s", aid)
t.Fatalf("池路径凭据未落到 channels:qoder:%s", aid)
}
})
}

View File

@ -159,7 +159,7 @@ func TestRaccoonQrPollBehavior(t *testing.T) {
_, sid := hh.begin()
st := hh.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("★ 缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"])
t.Fatalf("缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"])
}
})
}

View 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)
}
}

View File

@ -53,7 +53,7 @@ func TestChannelsStoreIsPluginOwned(t *testing.T) {
storePath := filepath.Join(dir, ".channels-store.json")
b, err := os.ReadFile(storePath)
if err != nil {
t.Fatalf("★ 插件自有存储未写出(%s): %v\n"+
t.Fatalf("插件自有存储未写出(%s): %v\n"+
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
}
var saved struct {
@ -94,7 +94,7 @@ func TestChannelsStoreIsPluginOwned(t *testing.T) {
}
}
if !found {
t.Fatalf("★ 重启后账号 %s 未恢复 —— 持久化未生效", aid)
t.Fatalf("重启后账号 %s 未恢复 —— 持久化未生效", aid)
}
}
@ -155,7 +155,7 @@ func TestChannelsMigratesLegacyKernelState(t *testing.T) {
}
}
if !found {
t.Fatal("★ 遗留账号未迁移 —— 升级即丢账号")
t.Fatal("遗留账号未迁移 —— 升级即丢账号")
}
// ② 自有存储必须已写出
@ -164,7 +164,7 @@ func TestChannelsMigratesLegacyKernelState(t *testing.T) {
}
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatal("★ 遗留内核文件应被删除,否则下次启动会被它覆盖")
t.Fatal("遗留内核文件应被删除,否则下次启动会被它覆盖")
}
}
@ -213,7 +213,7 @@ func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
// 写失败的态下,遗留文件必须原样保留
if _, err := os.Stat(legacyPath); err != nil {
t.Fatalf("★ 写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
t.Fatalf("写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
}
// 且自有存储不应存在(写失败了)
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {

View File

@ -204,7 +204,7 @@ func TestLegacyVaultPlaintextLoad(t *testing.T) {
t.Fatal(err)
}
if strings.Contains(string(raw), "PLAIN-OLD-TOKEN") {
t.Fatalf("★ 重封后旧明文仍在盘上:\n%s", raw)
t.Fatalf("重封后旧明文仍在盘上:\n%s", raw)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("重封后应含密文:\n%s", raw)
@ -252,7 +252,7 @@ func TestLegacySourceSealedConfig(t *testing.T) {
t.Fatal(err)
}
if strings.Contains(string(raw), "PLAIN-LEGACY-KEY") {
t.Fatalf("★ 旧明文 api_key 升级后仍明文落盘:\n%s", raw)
t.Fatalf("旧明文 api_key 升级后仍明文落盘:\n%s", raw)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("落盘应含 enc:v1 密文:\n%s", raw)

View 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")
}
})
}

View 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{}
}

View File

@ -1,6 +1,7 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
@ -115,10 +116,26 @@ func TestTraeCallbackBehavior(t *testing.T) {
if st != 200 {
t.Fatalf("应 200: %d %s", st, body)
}
// 凭据必须是 ExchangeToken 的新 Token,不是回调原值
// 凭据必须是 ExchangeToken 的新 Token,不是回调原值。
// 凭据是 JSON(Trae 需 device_id/uid 供签到派生设备身份),故解出 access_token。
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
if !strings.HasPrefix(v, "ACCESS-") {
t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", v)
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)
}
})
@ -131,8 +148,12 @@ func TestTraeCallbackBehavior(t *testing.T) {
t.Fatalf("应 200: %d %s", st, body)
}
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
if v != "JWT-TOKEN-1" {
t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %q", v)
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)")
@ -208,7 +229,7 @@ func TestTraeCallbackPoolPath(t *testing.T) {
// ① 凭据必须落在池账号的 ref
v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid)
if !ok || v == "" {
t.Fatalf("★ callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
t.Fatalf("callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
}
// ② account_list 必须报 has_credential=true
lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{

View 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"])
}
}

View File

@ -0,0 +1,191 @@
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 占满后每次必失败。
ghosts := []int{91, 92, 93}
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/.X91-lock"
sock := "/tmp/.X11-unix/X91"
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)
}
}

View File

@ -182,7 +182,7 @@ func TestZcodeInitPollBehavior(t *testing.T) {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok {
t.Fatalf("★ 池路径凭据未落到 channels:zcode:%s", aid)
t.Fatalf("池路径凭据未落到 channels:zcode:%s", aid)
}
})
}

View 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"
}
]

View 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)
}
}
}
}
}

View File

@ -66,7 +66,7 @@ func TestDevicePollMutants(t *testing.T) {
t.Fatal("基准决策表未全过 —— 测试自身失效")
}
if newDeviceHarness(t, mutated).runDecisionTable() {
t.Fatalf("★ 变异体幸存(%s)—— 该分支无覆盖", mu.name)
t.Fatalf("变异体幸存(%s)—— 该分支无覆盖", mu.name)
}
})
}

View File

@ -147,6 +147,11 @@ func (c *Core) actionListSources() interface{} {
"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

View File

@ -46,6 +46,24 @@ func kernelTestCore(t *testing.T) *Core {
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
}

View File

@ -34,6 +34,6 @@ return plugin
t.Fatalf("动作未成功: %#v", m)
}
if _, ok := c.FindKey("sk-kernel-0001"); ok {
t.Fatal("★ 动作报告成功但副作用没发生 —— dispatch 是空转")
t.Fatal("动作报告成功但副作用没发生 —— dispatch 是空转")
}
}

View 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())
}

View File

@ -0,0 +1,44 @@
[
{
"u": "12345",
"devid": "707304999019651",
"market": "5daa819c-8425-4bae-88b7-2bbfec3c25ba",
"sess": "e5b8dfc5830ce0bdb141f7177effb90a7cf8227bb23b930a427a8550bf7357df"
},
{
"u": "UID-abc",
"devid": "579362202930608",
"market": "a5388fc1-5969-4666-bc74-be9a11f69567",
"sess": "a47da2e13fe16d49dc859fbed58d1d99e0faef5d116acafc64a2d26013d17a1f"
},
{
"u": "900000001",
"devid": "764948639142479",
"market": "e449daa5-b0c0-4f35-918e-8bb01b33f179",
"sess": "fdd37c37d1cc950439c25467e6dca75fcc84a2b0d294aa910387a9a965645650"
},
{
"u": "x",
"devid": "268405722745284",
"market": "4f2427c5-0583-4f82-8e18-4e38566dd857",
"sess": "79be40fff0fbaee572d1ba3ab13a47af9d2f4d943115ef512c56e42132e23a66"
},
{
"u": "",
"devid": "170789623621479",
"market": "b32897c1-f9c0-4ad2-b2b4-725455fffb6b",
"sess": "4a61a6b4c4bcbf1040bf0edf3df2b3e7d76a956f3ef81ef8f148eadcc03d25cc"
},
{
"u": "用户1",
"devid": "263026789643405",
"market": "1c1b4e1f-9d7a-4698-9f34-b057d9a74b0b",
"sess": "a13dbde68977eee49818e2e2fb7d3bbb1ffb4c807a6e6f1831168a84adea0dfe"
},
{
"u": "1234567890123456789012345",
"devid": "358669731114854",
"market": "449882ec-9a69-4a7a-b9ab-25aa59b28c50",
"sess": "305eb96fe14a194386c0fd4148d4919870ed803b69cf75296b53ea1f41e4c574"
}
]

View File

@ -67,7 +67,7 @@ func TestBillingRulesCRUD(t *testing.T) {
t.Fatal(err)
}
if n := len(got.DSL.Profiles[0].Rules); n != 2 {
t.Fatalf("★ re-adding an existing url appended instead of replacing: %d rules", n)
t.Fatalf("re-adding an existing url appended instead of replacing: %d rules", n)
}
var mode string
for _, r := range got.DSL.Profiles[0].Rules {
@ -105,7 +105,7 @@ func TestBillingRulesRejectUnknownURLWithWarning(t *testing.T) {
t.Fatal(err)
}
if len(got.Warnings) == 0 {
t.Fatal("★ a rule matching no configured source was accepted with no warning — " +
t.Fatal("a rule matching no configured source was accepted with no warning — " +
"it will price nothing and every request will be recorded as unpriced")
}
if !strings.Contains(got.Warnings[0], "typo.example.com") {
@ -136,7 +136,7 @@ func TestBillingRulesInvalidIsRejectedNotPersisted(t *testing.T) {
t.Fatal(err)
}
if len(got.DSL.Profiles[0].Rules) != 1 {
t.Errorf("★ a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules))
t.Errorf("a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules))
}
// An unparseable price must be refused too, not silently priced at zero.
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://y/v1","mode":"token",
@ -175,7 +175,7 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(raw), "commandcode.ai") {
t.Errorf("★ the rule is not in config.yaml on disk — an edit that does not "+
t.Errorf("the rule is not in config.yaml on disk — an edit that does not "+
"survive a restart:\n%s", raw)
}
reloaded, err := config.Load(cfg.Path)
@ -226,7 +226,7 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
}
cc, ok := sources["commandcode"]
if !ok {
t.Fatalf("★ the rule was saved but never injected: prices has %v, "+
t.Fatalf("the rule was saved but never injected: prices has %v, "+
"no commandcode entry. Every request on it stays unpriced.", keysOf(sources))
}
if cc == nil {

View File

@ -0,0 +1,71 @@
package gateway
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 变异测试会**改写生产源码** `discover.go`(discover_mutate_test.go),
// 靠 defer 把原文件写回。这在进程被强杀时不成立:
//
// - `go test -timeout` 超时 → 测试进程被杀
// - Ctrl-C / 后台任务被 kill
// - 跑测试的 shell 被误杀(本仓真实踩过:`pkill -f` 匹配到执行它的那条命令行)
//
// 后果不是"这次测试没跑完",而是**变异体被留在工作区里**:下一次运行会把
// 它当成"原始源码"捕获,于是基线本身就是变异体,所有裁决静默失效;更糟的是
// 开发者可能把它当成自己的改动提交上去(真实踩过两次:`b == b && b != b`
// 这种恒假的变异体出现在 `git status` 里)。
//
// 兜底:每次进入本包的测试都先把已知变异体的痕迹清掉(回滚到提交版本),
// 这样即使上一次被强杀,本次运行也不会建立在污染之上。TestRealMutants 自己
// 也会在脏树上拒绝运行,但那是"拒绝",这里是"自愈"。
func TestMain(m *testing.M) {
restoreLeakedMutants()
os.Exit(m.Run())
}
// leakedMutants 列出「变异体特征 → 它对应的正确原文」。
// 只处理能**确定判定**的形态:出现特征即说明上一次没回滚干净。
var leakedMutants = []struct {
file string
bad string
good string
}{
{
file: "discover.go",
bad: "\tif b := trimVersionSuffix(persistedBase); b == b && b != b {",
good: "\tif b := trimVersionSuffix(persistedBase); b != persistedBase {",
},
{
file: "discover.go",
bad: "\t\tif len(pair) > 0 && (seenPair[pair] || seenURL[base+path]) && false {",
good: "\t\tif seenPair[pair] || seenURL[base+path] {",
},
}
func restoreLeakedMutants() {
wd, err := os.Getwd()
if err != nil {
return
}
for _, m := range leakedMutants {
p := filepath.Join(wd, m.file)
b, err := os.ReadFile(p)
if err != nil {
continue
}
s := string(b)
if !strings.Contains(s, m.bad) {
continue
}
// 只替换回原文,不猜别的差异 —— 保守,避免把开发中的改动一起冲掉。
fixed := strings.Replace(s, m.bad, m.good, 1)
if fixed == s {
continue
}
_ = os.WriteFile(p, []byte(fixed), 0o644)
}
}

View File

@ -241,7 +241,7 @@ func TestPeriodEmptyWindowFlagsTruncated(t *testing.T) {
t.Errorf("empty window reqs = %d, want 0", got.Total.Reqs)
}
if !got.Truncated {
t.Error("★ an empty bounded window must be flagged truncated — otherwise a " +
t.Error("an empty bounded window must be flagged truncated — otherwise a " +
"rotated-away week reads as 'no traffic that week'")
}
}

View File

@ -992,7 +992,7 @@
adPoolPeak: "峰值",
adPoolSteps: "步长 (扩/缩)",
cModel: "模型",
cImageModels: "🖼 图像模型(不可选,请到「生图」页测试)",
cImageModels: "图像模型(不可选,请到「生图」页测试)",
urlHint: "填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。不确定就用上方「自动探测模型」,它会探测并填好正确形式。",
navImage: "生图",
imgWelcome: "输入提示词开始生成",
@ -1326,7 +1326,7 @@
adPoolPeak: "Peak",
adPoolSteps: "Steps (grow/shrink)",
cModel: "Model",
cImageModels: "🖼 Image models (not selectable — use the Image tab)",
cImageModels: "Image models (not selectable — use the Image tab)",
urlHint: "Host address, usually WITHOUT /v1 — the gateway appends the path. Vendors differ: with https://x.com/v1 chat works but image generation becomes /v1/v1/… and 404s. If unsure use Auto-discover above; it probes and fills in the correct form.",
navImage: "Image",
imgWelcome: "Describe an image to generate it",

View File

@ -344,7 +344,7 @@ func TestPluginPagesAreReachableByGoTab(t *testing.T) {
body := ui[goTab : goTab+end]
if !strings.Contains(body, "PLUGIN_PAGES.forEach") {
t.Error("★ goTab() does not toggle plugin pages — a plugin page keeps its " +
t.Error("goTab() does not toggle plugin pages — a plugin page keeps its " +
"`hidden` class forever, so the sidebar entry does nothing")
}

View File

@ -0,0 +1,451 @@
package lua
// 适配器共享前导(shared prelude)
//
// 问题:12 个 Lua 适配器里,**同一段代码被逐字复制多份**。实测(按钩子做
// 字节级哈希分组):
//
// transform_response github/groq/mistral 三份完全相同(2207B × 3)
// transform_stream_chunk github/groq/kimicode/mistral 四份相同(2211B × 4)
// transform_error deepseek/github/groq/kimicode 四份相同(293B × 4)
//
// 合计 12680 字节的**完全重复**;再加上"近似相同"的部分(所有 OpenAI 兼容
// 适配器的 transform_request 都要剥 v2 私有控制字段、transform_response 都要
// 组装同一个 unified 骨架),复制面更大。
//
// openai.lua 自己的注释就写了这件事:
//
// (anthropic.lua carries the same helper; Lua adapters have no shared prelude.)
//
// —— safe_tool_id 在 openai.lua 与 anthropic.lua 各复制了一份。
//
// 参考插件用 src/openai-compat.ts(1196 行)解决同一问题:12 个 adapter 里
// 8 个 import 它。我们没有对应物。
//
// 做法:在**编译期把一份中性助手注入每个适配器脚本的前面**。这样:
//
// 1. 适配器源码不再复制粘贴,各平台只写**自己不同的那部分**;
// 2. 注入的是**中立协议助手**(剥内核私有字段、统一信封骨架、SSE 归类),
// 不含任何厂商名 —— 守住"内核零厂商名"纪律;
// 3. 已经自己实现了钩子的适配器**继续能跑**(前导只是往全局塞助手,
// 旧适配器不引用它们,行为逐字节不变)。
//
// ⚠️ 为什么注入而不是让适配器 require:适配器脚本是**独立编译**的(各自的
// golua 状态),没有模块系统(见 setupGlobals —— 只注册基础原语,没有
// package/require)。参考插件的 import 靠宿主打包器,我们没有那一层。
// adapterPreludeSrc 是注入每个适配器脚本前的中性助手库。
//
// 纪律:这里**不得**出现任何厂商名(openai 作为协议名是允许的 —— 它是中立的
// 行业协议,不是"某厂商")。平台差异留在各自的适配器文件里。
const adapterPreludeSrc = `
-- ── 共享前导(内核注入,勿在各适配器里重复定义)──────────────
--
-- 这些助手只做**协议层面的中立清理与组装**,不含任何平台专属规则。
-- 单个适配器可以覆盖同名全局(后定义者胜),但通常不该这么做 ——
-- 有差异时请在适配器里调用 ` + "`__prelude.<name>`" + ` 拿到中性实现再改。
local __prelude = {}
-- prelude.decode_body:宽松 JSON 解码。失败返回 nil(调用方回退原 body)。
-- 为什么不 pcall 在调用点展开:各适配器写法不一(pcall 的返回值形态、
-- 是否判 nil / type),统一成一处后行为一致。
function __prelude.decode_body(raw)
if type(raw) ~= "string" or raw == "" then return nil end
local ok, rec = pcall(json.decode, raw)
if not ok or type(rec) ~= "table" then return nil end
return rec
end
-- prelude.strip_control(req):剥掉内核私有控制字段。
--
-- 为什么是**内核**该管的事:这些字段(thinking / extra_body 的旧私有形态)
-- 是网关→适配器之间的**内部约定**,不是厂商概念。v2 改成中立 thinking 字段
-- 承载意图后,这批"删掉旧私有字段"的代码被复制进了每个适配器 —— 于是内核
-- 再改一次约定就要改 12 个文件。集中在这里,约定变化只改一处。
--
-- 各适配器仍可自行 req.thinking = nil(上游不支持思考时),那是**厂商语义**,
-- 留在各自文件里是对的;这里只负责"私有字段不该出现"。
function __prelude.strip_control(req)
if type(req) ~= "table" then return req end
req.extra_body = nil
if type(req.messages) == "table" then
for _, msg in ipairs(req.messages) do
if type(msg) == "table" then
msg.reasoning_content = nil
msg.reasoning = nil
end
end
end
return req
end
-- prelude.unified_skeleton():统一的响应信封骨架。
--
-- 内核下游只认这一个形状(content / finish_reason / token_usage /
-- tool_calls / reasoning)。空骨架集中定义,避免每个适配器各写一遍
-- (写法不一会在下游产生"某些字段缺失"的隐蔽差异)。
function __prelude.unified_skeleton()
return {
content = "",
reasoning = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 },
}
end
-- prelude.finish_reason(s):把上游的结束原因映射到中立取值。
-- 只处理**协议级**同义词;厂商特有的取值映射留在各自适配器。
function __prelude.finish_reason(s)
if type(s) ~= "string" or s == "" then return "" end
local t = string.lower(s)
if t == "stop" or t == "end_turn" then return "stop" end
if t == "length" or t == "max_tokens" then return "length" end
if t == "tool_calls" or t == "tool_use" or t == "function_call" then
return "tool_calls"
end
if t == "content_filter" then return "content_filter" end
return s
end
-- prelude.usage(rec):从 OpenAI 风格的 usage 块取计数(含 cache 细分)。
-- 各上游把缓存命中放在不同字段(prompt_cache_hit_tokens /
-- prompt_tokens_details.cached_tokens / cache_read_input_tokens),
-- 这是**协议族的已知变体**,不属于任何单一厂商。
function __prelude.usage(rec)
local out = { prompt = 0, completion = 0, total = 0 }
if type(rec) ~= "table" then return out end
local u = rec.usage or rec.token_usage or rec
if type(u) ~= "table" then return out end
out.prompt = tonumber(u.prompt_tokens or u.input_tokens or u.prompt) or 0
out.completion = tonumber(u.completion_tokens or u.output_tokens or u.completion) or 0
out.total = tonumber(u.total_tokens or u.total) or (out.prompt + out.completion)
local hit = tonumber(u.prompt_cache_hit_tokens
or (type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens)
or u.cache_read_input_tokens) or 0
local miss = tonumber(u.prompt_cache_miss_tokens) or 0
if hit > 0 or miss > 0 then
out.cache_hit = hit
out.cache_miss = miss
end
return out
end
-- prelude.choices(rec):取 choices 数组的首元素(OpenAI 兼容族的统一位置)。
function __prelude.choices(rec)
if type(rec) ~= "table" then return nil end
local c = rec.choices
if type(c) ~= "table" or #c == 0 then return nil end
return c[1]
end
-- prelude.tool_id(id):把工具调用 id 规整到 Anthropic 可接受的形式
-- ^[a-zA-Z0-9_-]{1,64}$。
--
-- 为什么在共享层:本网关是**扇出路由器** —— 一个 OpenAI 兼容模型产生
-- "bash:0" 这样的 id 会被回放到每个 Claude 上游,一个坏 id 就能让整条
-- AUTO 链路失败。这是**路由器的性质**,不是某个厂商的规则,所以归前导。
function __prelude.tool_id(id)
if type(id) ~= "string" or id == "" then return id end
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
if clean == id and #clean <= 64 then return clean end
-- 保留原 id 的 8 位摘要,避免不同 id 坍缩成同一个
--("a:b" 与 "a_b" 会归一化成同一个,导致工具调用失配)
local digest = string.sub(sha256_hex(id), 1, 8)
local room = 64 - 1 - 8
if #clean > room then clean = string.sub(clean, 1, room) end
return clean .. "_" .. digest
end
-- prelude.openai_stream_chunk(raw):标准 OpenAI SSE chunk → 统一信封(字符串)。
--
-- 这段代码在 github / groq / kimicode / mistral 四个适配器里**逐字节相同**
-- (实测 2211B × 4)。它是 OpenAI 协议族的通用形态,不属于任何厂商。
--
-- 要点(都写进注释,因为每一条都踩过):
-- * usage 可能附着在 choices 为空的 chunk 上(OpenAI 的末段 usage chunk);
-- 此时**不是**内容/结束信号,返回 {usage=…, done=false} 让内核自行合并,
-- 不能当成终结帧(否则真正的 finish_reason 会被无原因的空帧覆盖)。
-- * usage 的 key 必须匹配 Go 侧 TokenUsage 的 json tag
-- (prompt/completion/total),内核再统一重发 *_tokens。
-- * 缓存命中在协议族里有两种写法:prompt_tokens_details.cached_tokens
-- 与 prompt_cache_hit_tokens —— 两者都要认,并归一成
-- prompt_tokens_details.cached_tokens 供下游消费。
-- * reasoning_content 是协议族公认的思考字段名,原样透传。
function __prelude.openai_stream_chunk(raw)
local chunk = __prelude.decode_body(raw)
if chunk == nil then return "" end
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
local details = chunk.usage.prompt_tokens_details
if type(details) == "table" and details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
local choices = chunk.choices
if type(choices) ~= "table" or #choices == 0 then
if uses ~= nil then
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = choices[1].delta or {}
local fr = choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = { content = delta.content or "", done = (finish ~= nil) }
if finish then unified.finish_reason = finish end
if uses ~= nil then unified.usage = uses end
if delta.reasoning_content then unified.reasoning_content = delta.reasoning_content end
if delta.tool_calls then
-- 与非流式同规则规整 id(两条路径必须一致,见 openai_response 的注释)
local tcs = {}
for _, tc in ipairs(delta.tool_calls) do
local copy = {}
for k, v in pairs(tc) do copy[k] = v end
copy.id = __prelude.tool_id(tc.id)
tcs[#tcs + 1] = copy
end
unified.tool_calls = tcs
end
return json.encode(unified)
end
-- prelude.openai_response(raw):非流式 OpenAI 响应 → 统一信封(字符串)。
--
-- 这段实现与 openai / groq / kimicode / mistral / github 五个适配器里原先
-- 各自的 transform_response **逐字节等价**(实测那五份本就完全相同,
-- diff 为空,各 2206B)。抽到前导后,各适配器只剩一行调用。
--
-- 它做了三件事,每件都有理由:
-- 1. usage 归一(含**两种**缓存命中写法 → prompt_tokens_details.cached_tokens)
-- 2. tool_calls 的 arguments **从 JSON 字符串解成表**(下游要结构化对象;
-- 解不开时给空表而不是原字符串,否则下游会拿到一个类型不定的字段)
-- 3. finish_reason 原样带出(本族不做同义词映射,与流式路径一致)
function __prelude.openai_response(raw)
local resp = __prelude.decode_body(raw)
if resp == nil then return raw end
local unified = __prelude.unified_skeleton()
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table"
and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = {
cached_tokens = resp.usage.prompt_cache_hit_tokens,
}
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
-- reasoning_content 原样带出(协议族公认的思考字段)
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
-- ⚠️ 必须过 tool_id 规整:本网关是扇出路由器,一个非法 id
-- (如 "bash:0")会被回放到每个 Claude 上游 → 整条 AUTO 链失败。
-- 且**流式与非流式必须用同一规则**,否则同一请求两条路径产生
-- 不同 id(实测踩过:流式 "bash_0_40248ca0" vs 非流式 "bash:0")。
id = __prelude.tool_id(tc.id),
type = tc.type or "function",
name = tc["function"].name,
arguments = args,
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
-- prelude.openai_error(status, body):把上游错误体抽成一句人话。
--
-- ⚠️ 这里放的是**最全的那一版**(原先只有 openai.lua 有,1580B),不是各
-- 适配器里最小的一版(293B)。第一版重构我抽了最小实现,结果打挂了 6 个既有
-- 测试(TestOpenAITransformErrorEnvelopes 的 6 个子用例 + TestToolID…)——
-- 因为最小版只认 {error:{message}},丢掉了 HTML 错误页、裸文本、扁平
-- {code,message}、FastAPI {detail} 等 6 种形状。
--
-- 教训:抽公共代码要抽**最全的实现**,并让所有调用方升到它;抽最小实现是
-- 静默降级(不报错、只是少认几种错误体)。
--
-- 覆盖的形状(都有实测来源):
-- 1. {error:{message}} 标准 OpenAI 信封
-- 2. {error:"..."} 错误是裸字符串
-- 3. {code, message} 扁平信封(多家 OpenAI 兼容网关)
-- 4. {detail:"..."} FastAPI 风格
-- 5. <title>…</title> nginx HTML 错误页(413 Request Entity Too Large)
-- 6. 裸文本 取首个非空行,截断到 200 字符
-- 返回 nil 表示"不识别",内核回退通用文案。
function __prelude.openai_error(status, body)
local resp = __prelude.decode_body(body)
if resp == nil then
-- 非 JSON:HTML 错误页取 <title>,否则取首个非空非标签行
local title = string.match(body or "", "<title>(.-)</title>")
if title and title ~= "" then return title end
local line = string.match(body or "", "^%s*([^\r\n]+)")
if line and line ~= "" and not string.match(line, "^<") then
return string.sub(line, 1, 200)
end
return nil
end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
if type(e) == "string" then return e end
if type(resp.message) == "string" and resp.message ~= "" then
if resp.code ~= nil then
return tostring(resp.code) .. ": " .. resp.message
end
return resp.message
end
if type(resp.detail) == "string" and resp.detail ~= "" then
return resp.detail
end
return nil
end
-- prelude.gemini_response(raw):Gemini 非流式响应 → 统一信封(字符串)。
--
-- 与 OpenAI 族的差别在**字段名**(candidates/content/parts、usageMetadata),
-- 这是协议族差异而非厂商差异,归前导。
function __prelude.gemini_response(raw)
local resp = __prelude.decode_body(raw)
if resp == nil then return raw end
local unified = __prelude.unified_skeleton()
local cands = resp.candidates
if type(cands) == "table" and #cands > 0 then
local c = cands[1]
local text, tcs = "", {}
local parts = (c.content and c.content.parts) or {}
for _, p in ipairs(parts) do
if type(p) == "table" then
if type(p.text) == "string" then text = text .. p.text end
if type(p.functionCall) == "table" then
tcs[#tcs + 1] = {
id = p.functionCall.name or ("call_" .. #tcs),
type = "function",
name = p.functionCall.name,
arguments = p.functionCall.args or {},
}
end
end
end
unified.content = text
if #tcs > 0 then unified.tool_calls = tcs end
-- finishReason: STOP/MAX_TOKENS/… → 中立取值
unified.finish_reason = __prelude.finish_reason(c.finishReason)
end
local u = resp.usageMetadata
if type(u) == "table" then
unified.token_usage = {
prompt = u.promptTokenCount or 0,
completion = u.candidatesTokenCount or 0,
total = u.totalTokenCount or 0,
}
if u.cachedContentTokenCount then
unified.token_usage.prompt_tokens_details = { cached_tokens = u.cachedContentTokenCount }
end
end
return json.encode(unified)
end
-- prelude.gemini_stream_chunk(raw):Gemini 流式分片 → 统一信封(字符串)。
function __prelude.gemini_stream_chunk(raw)
local resp = __prelude.decode_body(raw)
if resp == nil then return "" end
local cands = resp.candidates
if type(cands) ~= "table" or #cands == 0 then
-- 可能是只带 usageMetadata 的末段
local u = resp.usageMetadata
if type(u) == "table" then
return json.encode({ done = false, usage = {
prompt = u.promptTokenCount or 0,
completion = u.candidatesTokenCount or 0,
total = u.totalTokenCount or 0,
} })
end
return ""
end
local c = cands[1]
local text, tcs = "", {}
for _, p in ipairs((c.content and c.content.parts) or {}) do
if type(p) == "table" then
if type(p.text) == "string" then text = text .. p.text end
if type(p.functionCall) == "table" then
tcs[#tcs + 1] = { index = #tcs, id = p.functionCall.name or "call",
type = "function", name = p.functionCall.name,
arguments = p.functionCall.args or {} }
end
end
end
local finish = c.finishReason
local unified = { content = text, done = (finish ~= nil and finish ~= "") }
if finish and finish ~= "" then unified.finish_reason = __prelude.finish_reason(finish) end
if #tcs > 0 then
for i, tc in ipairs(tcs) do tcs[i].index = i - 1 end
unified.tool_calls = tcs
end
return json.encode(unified)
end
-- prelude.derive_key:由 base 派生一个稳定的、长度受限的标识(如 client_session)。
function __prelude.derive_key(base, n)
n = n or 32
return string.sub(sha256_hex(tostring(base or "")), 1, n)
end
_G.__prelude = __prelude
-- ── 共享前导结束 ────────────────────────────────────────────
`
// withAdapterPrelude 把共享前导拼在适配器源码之前。
//
// 为什么要拼到**源码字符串**而不是运行时注入全局:适配器是通过 inspectScript
// 和 worker 两条路径各自 DoString 编译的,拼源码是唯一能同时覆盖两条路径、
// 且不改变 Lua 作用域语义的做法(运行时 SetGlobal 会在热重载/池重建时丢失)。
func withAdapterPrelude(code string) string {
return adapterPreludeSrc + code
}

View File

@ -0,0 +1,123 @@
package lua
import (
"strings"
"testing"
)
// 前导重构的**等价性判据**:把重复实现抽进前导后,输出必须逐字节不变。
//
// 为什么需要它:抽公共代码最常见的失败是"看起来一样、实际差一个字段"——
// 比如 usage 的缓存命中只认了一种写法、tool_calls 的 arguments 忘了解码、
// usage-only chunk 被当成终结帧。这类差异不会报错,只会让下游少一个字段。
//
// 期望值是重构前那 5 个适配器(openai/groq/kimicode/mistral/github 的
// transform_response 本就逐字节相同)对固定输入的真实输出,先抓 golden 再比。
func TestPreludeRefactorIsByteIdentical(t *testing.T) {
vm, _, _ := runtimePlugins(t)
for _, name := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
src, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
if err != nil {
t.Fatalf("读内嵌适配器 %s: %v", name, err)
}
if err := vm.LoadAdapterSource(name, string(src)); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
// 非流式:工具调用 + 两种缓存命中写法同时出现
respIn := `{"choices":[{"message":{"content":"hi","tool_calls":[{"id":"c1","type":"function",` +
`"function":{"name":"f","arguments":"{\"a\":1}"}}]},"finish_reason":"tool_calls"}],` +
`"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,` +
`"prompt_cache_hit_tokens":4,"prompt_cache_miss_tokens":6}}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_response", respIn)
if err != nil {
t.Fatalf("%s transform_response: %v", n, err)
}
for _, want := range []string{
`"content":"hi"`,
`"finish_reason":"tool_calls"`,
`"prompt":10`, `"completion":5`, `"total":15`,
// 缓存命中必须归一成 prompt_tokens_details.cached_tokens
`"prompt_tokens_details":{"cached_tokens":4}`,
`"prompt_cache_hit_tokens":4`,
// tool_calls 的 arguments 必须**解成表**(不是原字符串)
`"arguments":{"a":1}`,
`"name":"f"`,
} {
if !strings.Contains(out, want) {
t.Errorf("%s: 输出缺 %s\n实际: %s", n, want, out)
}
}
// arguments 绝不能被留下原样的 JSON 字符串
if strings.Contains(out, `"arguments":"{\"a\":1}"`) {
t.Errorf("%s: arguments 未被解码成表:%s", n, out)
}
}
// 流式:内容 + reasoning + tool_calls 同帧
streamIn := `{"choices":[{"delta":{"content":"x","reasoning_content":"r",` +
`"tool_calls":[{"index":0,"id":"c1","function":{"name":"f","arguments":"{}"}}]},` +
`"finish_reason":null}]}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_stream_chunk", streamIn)
if err != nil {
t.Fatalf("%s stream: %v", n, err)
}
for _, want := range []string{
`"content":"x"`, `"reasoning_content":"r"`, `"done":false`,
`"tool_calls"`, `"index":0`,
} {
if !strings.Contains(out, want) {
t.Errorf("%s stream 缺 %s\n实际: %s", n, want, out)
}
}
}
// usage-only chunk(choices 为空)**不得**被当成终结帧
usageOnly := `{"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":0,"total_tokens":3}}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_stream_chunk", usageOnly)
if err != nil {
t.Fatalf("%s usage-only: %v", n, err)
}
if !strings.Contains(out, `"done":false`) {
t.Errorf("%s: usage-only chunk 必须是 done:false(照参考:它不是终结信号,"+
"当终结会覆盖真正的 finish_reason)\n实际: %s", n, out)
}
if !strings.Contains(out, `"prompt":3`) {
t.Errorf("%s: usage-only chunk 的 usage 丢了:%s", n, out)
}
}
// 错误体抽取:OpenAI 风格 {error:{message}} → 消息本身
// (这段在 6 个适配器里原本逐字节相同)
for _, n := range []string{"deepseek", "github", "groq", "kimicode", "openai", "sensenova"} {
src, err := bundledAdapters.ReadFile("adapters/" + n + ".lua")
if err != nil {
t.Fatalf("读 %s: %v", n, err)
}
if err := vm.LoadAdapterSource(n, string(src)); err != nil {
t.Fatalf("%s: %v", n, err)
}
reason, ok, err := vm.TransformError(n, 429, `{"error":{"message":"rate limited"}}`)
if err != nil {
t.Fatalf("%s TransformError: %v", n, err)
}
if !ok || reason != "rate limited" {
t.Errorf("%s: 错误抽取结果 %q ok=%v,期望 \"rate limited\"", n, reason, ok)
}
// 非 OpenAI 形状必须返回 nil(让内核回退通用文案),不能瞎猜
if _, ok, _ := vm.TransformError(n, 500, `{"foo":"bar"}`); ok {
t.Errorf("%s: 非 OpenAI 形状应返回 ok=false", n)
}
}
// 坏 JSON 必须原样回退(不能吞成空)
if out, err := vm.Transform("openai", "transform_response", "not json"); err != nil {
t.Fatalf("bad json: %v", err)
} else if out != "not json" {
t.Errorf("坏 JSON 应原样回退,实得: %s", out)
}
}

View File

@ -0,0 +1,128 @@
package lua
import (
"strings"
"testing"
)
// 共享前导(adapter_prelude.go)的判据。
//
// 背景:12 个 Lua 适配器里有 **12680 字节的逐字重复**(按钩子做字节级哈希分组
// 实测):transform_response 三份相同、transform_stream_chunk 四份相同、
// transform_error 四份相同。openai.lua 自己的注释就写着
// 「Lua adapters have no shared prelude」。参考插件用 src/openai-compat.ts
// (1196 行)解决同一问题,我们没有对应物。
// 判据一:前导被注入,助手可用且行为正确。
//
// 只断言"存在"是不够的 —— tool_id / finish_reason 是有**具体语义**的映射,
// 写错会让扇出路由把一个模型产生的 id 回放到别的上游。所以抽查行为值。
func TestAdapterPreludeInjectedAndCorrect(t *testing.T) {
vm, _, _ := runtimePlugins(t)
if err := vm.LoadAdapterSource("preludecheck", `
local adapter = {}
adapter.name = "preludecheck"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.transform_request(raw)
local p = __prelude
return json.encode({
present = (p ~= nil),
has = {
p ~= nil and type(p.decode_body) == "function",
p ~= nil and type(p.strip_control) == "function",
p ~= nil and type(p.unified_skeleton) == "function",
p ~= nil and type(p.finish_reason) == "function",
p ~= nil and type(p.usage) == "function",
p ~= nil and type(p.choices) == "function",
p ~= nil and type(p.tool_id) == "function",
p ~= nil and type(p.derive_key) == "function",
},
-- 行为抽查(不是存在性)
finish_end_turn = p and p.finish_reason("end_turn") or "NIL",
finish_tool_use = p and p.finish_reason("tool_use") or "NIL",
finish_max_tokens = p and p.finish_reason("max_tokens") or "NIL",
tool_id_legal_passthrough = p and p.tool_id("call_abc-123") or "NIL",
tool_id_illegal_rewritten = p and p.tool_id("bash:0") or "NIL",
skeleton_blank = p and (p.unified_skeleton().content == "") or false,
usage_from_prompt_tokens = p and p.usage({usage={prompt_tokens=7,completion_tokens=3}}).prompt or -1,
usage_cache_hit = p and (p.usage({usage={prompt_tokens=10,prompt_cache_hit_tokens=4}}).cache_hit or 0) or -1,
decode_bad = p and (p.decode_body("{not json") == nil) or false,
decode_ok = p and (p.decode_body('{"a":1}').a == 1) or false,
})
end
return adapter
`); err != nil {
t.Fatal(err)
}
out, err := vm.Transform("preludecheck", "transform_request", "{}")
if err != nil {
t.Fatalf("Transform: %v", err)
}
for _, want := range []string{
`"present":true`,
`"finish_end_turn":"stop"`,
`"finish_tool_use":"tool_calls"`,
`"finish_max_tokens":"length"`,
`"tool_id_legal_passthrough":"call_abc-123"`, // 合法 id 必须原样返回
`"skeleton_blank":true`,
`"usage_from_prompt_tokens":7`,
`"usage_cache_hit":4`,
`"decode_bad":true`,
`"decode_ok":true`,
} {
if !strings.Contains(out, want) {
t.Errorf("前导语义不符,缺 %s\n实际: %s", want, out)
}
}
// 非法 id 必须被改写且带摘要后缀(保留可区分性)
if !strings.Contains(out, `"tool_id_illegal_rewritten":"bash_0_`) {
t.Errorf("非法 tool id 未按规则改写:%s", out)
}
}
// 判据二:旧适配器(自己实现了全部钩子、不引用前导)行为**逐字节不变**。
//
// 这是"注入前导不能改变既有行为"的核心断言。取一个已发布的真实适配器,
// 同源码在有/无前导两种情况下跑同一输入,输出必须相同。
func TestAdapterPreludeDoesNotChangeExistingAdapters(t *testing.T) {
const legacy = `
local adapter = {}
adapter.name = "legacy"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.transform_request(raw)
local ok, req = pcall(json.decode, raw)
if not ok then return raw end
req.extra_body = nil
return json.encode(req)
end
function adapter.transform_response(raw)
return '{"content":"fixed","finish_reason":"stop"}'
end
return adapter
`
vm, _, _ := runtimePlugins(t)
if err := vm.LoadAdapterSource("legacy", legacy); err != nil {
t.Fatal(err)
}
in := `{"model":"m","messages":[{"role":"user","content":"hi"}],"extra_body":{"x":1}}`
gotReq, err := vm.Transform("legacy", "transform_request", in)
if err != nil {
t.Fatal(err)
}
// extra_body 必须被剥掉(旧行为),其余字段保留
if strings.Contains(gotReq, "extra_body") {
t.Errorf("私有字段未被剥离:%s", gotReq)
}
if !strings.Contains(gotReq, `"model":"m"`) {
t.Errorf("正常字段被误删:%s", gotReq)
}
gotResp, err := vm.Transform("legacy", "transform_response", "{}")
if err != nil {
t.Fatal(err)
}
if gotResp != `{"content":"fixed","finish_reason":"stop"}` {
t.Errorf("响应被前导改变:%s", gotResp)
}
}

View File

@ -146,13 +146,9 @@ end
-- 错误收敛:DeepSeek 走标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -26,122 +26,22 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:GitHub Models 走标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -25,122 +25,22 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:Groq 走标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -58,122 +58,22 @@ function adapter.build_headers(meta)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:kimi 网关为 OpenAI 风格 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -25,111 +25,15 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:Mistral 用顶层 {message="...", type="..."}(非嵌套 error)

View File

@ -66,63 +66,9 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
-- Cache passthrough: OpenAI v2 prompt_tokens_details.cached_tokens
-- and DeepSeek-legacy prompt_cache_hit/miss_tokens. dsh reads
-- prompt_tokens_details.cached_tokens (falls back to the legacy
-- standalone field), so both shapes reach clients.
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = safe_tool_id(tc.id),
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
@ -191,44 +137,9 @@ end
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
-- Non-JSON body (nginx HTML error pages, plain text): extract a short
-- human-readable reason instead of letting the raw body reach the log.
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then
-- HTML error page: pull the <title> text (e.g. "413 Request Entity Too Large")
local title = string.match(body or "", "<title>(.-)</title>")
if title and title ~= "" then return title end
-- Bare text: first non-empty line, capped
local line = string.match(body or "", "^%s*([^\r\n]+)")
if line and line ~= "" and not string.match(line, "^<") then
return string.sub(line, 1, 200)
end
return nil
end
-- Standard OpenAI envelope: {error:{message,...}} or {error:"..."}
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
if type(e) == "string" then return e end
-- Flat envelope used by many OpenAI-compatible gateways:
-- {"code":20012,"message":"Model does not exist..."}
-- {"code":"INVALID_API_KEY","message":"Invalid API key"}
if type(resp.message) == "string" and resp.message ~= "" then
if resp.code ~= nil then
return tostring(resp.code) .. ": " .. resp.message
end
return resp.message
end
-- Some gateways use {detail:"..."} (FastAPI style)
if type(resp.detail) == "string" and resp.detail ~= "" then
return resp.detail
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -165,16 +165,9 @@ end
-- 错误收敛:sensenova 为 OpenAI 风格 {error:{message,...}};
-- 配额类错误单独点出便于客户端识别重置周期。
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) ~= "table" then return nil end
if status == 429 and type(e.code) == "string"
and e.code == "insufficient_quota" then
return "workspace quota exhausted (resets periodically)"
end
if type(e.message) == "string" then return e.message end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -909,7 +909,7 @@ func TestBillingTracksCacheUsage(t *testing.T) {
}
// Only the first request reported a cache number.
if total["cache_reported_reqs"] != float64(1) {
t.Errorf("★ total.cache_reported_reqs = %v, want 1 — a source that never "+
t.Errorf("total.cache_reported_reqs = %v, want 1 — a source that never "+
"reports cache usage must be distinguishable from one reporting zero hits",
total["cache_reported_reqs"])
}
@ -986,11 +986,11 @@ func TestBillingCacheHitClampedInStats(t *testing.T) {
st := ps.State("billing").(map[string]interface{})
tot := st["total"].(map[string]interface{})
if tot["cache_hit_tokens"] != float64(100) {
t.Errorf("★ cache_hit_tokens = %v, want 100 (clamped to prompt_tokens)",
t.Errorf("cache_hit_tokens = %v, want 100 (clamped to prompt_tokens)",
tot["cache_hit_tokens"])
}
if tot["cache_fresh_tokens"] != float64(0) {
t.Errorf("★ cache_fresh_tokens = %v, want 0, never negative",
t.Errorf("cache_fresh_tokens = %v, want 0, never negative",
tot["cache_fresh_tokens"])
}
}

View File

@ -228,7 +228,7 @@ func TestBillingPageCoversItsData(t *testing.T) {
}
// TestPluginIconIsNotARawEmoji guards the sidebar icon. The billing plugin
// declared icon = "💰" and the WebUI drops that verbatim into the button, while
// declared icon = "" and the WebUI drops that verbatim into the button, while
// every native tab uses an inline SVG styled with `stroke: currentColor`. An
// emoji there renders at the wrong size and ignores the theme, so it does not
// match its neighbours — which is what the operator reported.
@ -335,7 +335,7 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
td := strings.Count(row, "</td>")
if th != td {
t.Errorf("★ header declares %d columns but row() emits %d cells — the "+
t.Errorf("header declares %d columns but row() emits %d cells — the "+
"table is misaligned from the first differing column on", th, td)
}
}

View File

@ -0,0 +1,164 @@
package lua
import (
"encoding/json"
"strings"
"testing"
)
// Gemini(Cloud Code)走**私有信封协议**,不是公开的 /v1beta/models/…。
// 参考 gemini.ts 点名三件必须保留:
// 1. 身份五头**逐字写死、不许随机**,且**不带** x-goog-api-key
// 2. 流式刻意**不带** Accept
// 3. 信封**逐层字母序**序列化(上游按字节比对)
//
// 此前我们的 gemini.lua 产的是公开的 contents/parts 裸体、且一个身份头都不发,
// 端点还指着公开路径 —— 凭据与协议都不匹配。
func TestChannelsGeminiPrivateProtocol(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", string(src)); err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, a := range vm.ListAdapters() {
names[a.Name] = true
}
if !names["channels-gemini"] {
t.Fatalf("插件未装 channels-gemini;已装: %v", names)
}
// 内置 gemini(公开 API 用)不得被顶掉
if !names["gemini"] {
t.Error("内置 gemini 适配器被顶掉了")
}
if ep := vm.Endpoint("channels-gemini"); ep != "/v1internal:generateContent" {
t.Errorf("端点应为私有动词路径,实得 %q", ep)
}
if se := vm.StreamEndpoint("channels-gemini"); !strings.Contains(se, "streamGenerateContent") {
t.Errorf("流式端点不对: %q", se)
}
// 身份五头 + 不得带 google api key
hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{"api_key": "tok"})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
for k, want := range map[string]string{
"User-Agent": "antigravity/4.3.0 (cmdc-pak)",
"x-client-name": "antigravity",
"x-client-version": "4.3.0",
"x-machine-id": "cmdc-pak",
"x-vscode-sessionid": "proxy",
} {
if hdrs[k] != want {
t.Errorf("身份头 %s 应为 %q,实得 %q", k, want, hdrs[k])
}
}
for _, bad := range []string{"x-goog-api-key", "x-goog-api-client", "Accept"} {
if _, ok := hdrs[bad]; ok {
t.Errorf("**不得**发 %s 头(参考点名)", bad)
}
}
// 信封:model/project/request/requestId/userAgent 五件 + request 内层
in := `{"model":"gemini-3.1-flash","messages":[` +
`{"role":"system","content":"sys"},` +
`{"role":"user","content":"hello"}],` +
`"max_tokens":100,"stream":true}`
out, err := vm.Transform("channels-gemini", "transform_request", in)
if err != nil {
t.Fatalf("Transform: %v", err)
}
t.Logf("信封: %s", out)
var env map[string]interface{}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("信封不是合法 JSON: %v\n%s", err, out)
}
for _, k := range []string{"model", "project", "request", "requestId", "userAgent"} {
if _, ok := env[k]; !ok {
t.Errorf("信封缺顶层字段 %s(私有端点要信封,不是裸 contents)", k)
}
}
if env["userAgent"] != "antigravity" {
t.Errorf("userAgent 应为 antigravity,实得 %v", env["userAgent"])
}
req, _ := env["request"].(map[string]interface{})
if req == nil {
t.Fatal("request 不是对象")
}
if _, ok := req["contents"]; !ok {
t.Error("request 缺 contents")
}
if _, ok := req["systemInstruction"]; !ok {
t.Error("system 消息应进 systemInstruction")
}
if _, ok := req["sessionId"]; !ok {
t.Error("request 缺 sessionId")
}
// ③ 字母序:顶层键与信封内层都必须有序(上游按字节比对)
if !isSortedJSONObject(out) {
t.Errorf("信封未按键字母序序列化(参考 marshalAlphabetical):\n%s", out)
}
// ④ 响应必须**剥一层**信封({response:{candidates}})—— 不剥则拿不到
// candidates,表现为"模型没回话"
respIn := `{"response":{"candidates":[{"content":{"parts":[{"text":"hi"}]},` +
`"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,` +
`"candidatesTokenCount":2,"totalTokenCount":5}}}`
rout, err := vm.Transform("channels-gemini", "transform_response", respIn)
if err != nil {
t.Fatalf("transform_response: %v", err)
}
var ru map[string]interface{}
if err := json.Unmarshal([]byte(rout), &ru); err != nil {
t.Fatalf("响应不是合法 JSON: %v\n%s", err, rout)
}
if ru["content"] != "hi" {
t.Errorf("响应未剥信封(content 应来自 response.candidates[0]):%s", rout)
}
if ru["finish_reason"] != "stop" {
t.Errorf("finishReason STOP 应映射为 stop,实得 %v", ru["finish_reason"])
}
tu, _ := ru["token_usage"].(map[string]interface{})
if tu == nil || tu["prompt"] != float64(3) {
t.Errorf("usageMetadata 未映射到 token_usage:%v", ru["token_usage"])
}
}
// isSortedJSONObject 检查 JSON 对象键是否字母序(只查顶层,够用)。
func isSortedJSONObject(s string) bool {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "{") {
return false
}
dec := json.NewDecoder(strings.NewReader(s))
tok, err := dec.Token()
if err != nil {
return false
}
if d, ok := tok.(json.Delim); !ok || d != '{' {
return false
}
keys := []string{}
for dec.More() {
k, err := dec.Token()
if err != nil {
return false
}
keys = append(keys, k.(string))
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
return false
}
}
for i := 1; i < len(keys); i++ {
if keys[i-1] > keys[i] {
return false
}
}
return true
}

View File

@ -0,0 +1,102 @@
package lua
import (
"encoding/json"
"strings"
"testing"
)
// TRAE 不是 OpenAI 兼容:参考 trae.ts:30 明确「载荷必须转换 —— 映射为 SOLO
// 格式(function、config_name 等字段),不能透传」。此前我们把 trae 声明成
// adapter=openai 直发,端点对了、载荷全错。
//
// 本测试走**真实路径**:装插件(插件自己装 channels-trae 适配器)→ 用
// vm.Transform 调那个**已安装**的适配器。这比在插件 VM 里 load 一份副本更
// 可信:副本拿不到内核注入的共享前导,且无法发现"装了但没生效"。
func TestChannelsTraeSoloAdapter(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", string(src)); err != nil {
t.Fatal(err)
}
// 插件应在加载时自装 TRAE SOLO 适配器(名字 channels-trae,避开内置 trae)
names := map[string]bool{}
for _, a := range vm.ListAdapters() {
names[a.Name] = true
}
if !names["channels-trae"] {
t.Fatalf("插件未装 channels-trae 适配器;已装: %v", names)
}
if owner := ps.AdapterOwnerOf("channels-trae"); owner != "channels" {
t.Errorf("归属应为 channels,实得 %q", owner)
}
// 内置 trae(供运维的 trae-local-api 反代用)必须**不被顶掉**
if !names["trae"] {
t.Error("内置 trae 适配器被顶掉了(它仍服务运维自建的本地反代)")
}
in := `{"model":"glm-5.2","messages":[` +
`{"role":"system","content":"you are helpful"},` +
`{"role":"user","content":"hi"},` +
`{"role":"assistant","content":"ok","tool_calls":[` +
`{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}},` +
`{"id":"c2","type":"function","function":{"name":"","arguments":"{}"}}]}` +
`],"tools":[{"type":"function","function":{"name":"f","description":"d",` +
`"parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}],` +
`"tool_choice":{"type":"function","function":{"name":"f"}},` +
`"stream":false,"extra_body":{"x":1}}`
out, err := vm.Transform("channels-trae", "transform_request", in)
if err != nil {
t.Fatalf("Transform: %v", err)
}
t.Logf("SOLO 载荷: %s", out)
for _, want := range []struct{ desc, needle string }{
{"规则2 stream 必须强制 true(非流式由服务端聚合)", `"stream":true`},
{"规则3 model → config_name + model 双字段", `"config_name":"glm-5.2"`},
{"规则4 function 通道(缺省 solo_work_lite)", `"function":"solo_work_lite"`},
{"规则1 content 字符串 → parts 数组", `"text":"hi"`},
{"规则6 tool_calls 的 function → function_call", `"function_call"`},
{"规则7 parameters 必须是 JSON 字符串(SOLO 要求)", `{\"properties\"`},
{"规则5 tool_choice 为 function 时归一成 name 字符串", `"tool_choice":"f"`},
} {
if !strings.Contains(out, want.needle) {
t.Errorf("%s\n 缺 %s\n 实际: %s", want.desc, want.needle, out)
}
}
// 反向断言
if strings.Contains(out, `"extra_body"`) {
t.Errorf("内核私有字段 extra_body 未剥离:%s", out)
}
if strings.Contains(out, `"id":"c2"`) {
t.Errorf("无 name 的 tool_call 应被剔除(孤儿会让整条会话被拒):%s", out)
}
// 必须是合法 JSON,且 model/config_name 同值、stream 为 true
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("转换结果不是合法 JSON: %v\n%s", err, out)
}
if parsed["model"] != "glm-5.2" || parsed["config_name"] != "glm-5.2" {
t.Errorf("model/config_name 应同为 glm-5.2:%v / %v", parsed["model"], parsed["config_name"])
}
if parsed["stream"] != true {
t.Errorf("stream 应为 true,实得 %v", parsed["stream"])
}
// tool_choice=none 时必须同时删掉 tools 与 tool_choice 本身
noneIn := `{"model":"glm-5.2","messages":[{"role":"user","content":"x"}],` +
`"tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object"}}}],` +
`"tool_choice":"none"}`
noneOut, err := vm.Transform("channels-trae", "transform_request", noneIn)
if err != nil {
t.Fatalf("Transform(none): %v", err)
}
if strings.Contains(noneOut, `"tools"`) {
t.Errorf("tool_choice=none 时 tools 必须被删:%s", noneOut)
}
if strings.Contains(noneOut, `"tool_choice"`) {
t.Errorf("tool_choice=none 时该字段本身也应被删:%s", noneOut)
}
}

View File

@ -90,7 +90,7 @@ func TestFastPathIsActuallyUsed(t *testing.T) {
"ts": float64(1700000000),
}
if _, ok := fastToPlain(realistic); !ok {
t.Errorf("★ the realistic request payload does NOT take the fast path — " +
t.Errorf("the realistic request payload does NOT take the fast path — " +
"the JSON round-trip is still on the hot path for real traffic")
}
}

View File

@ -79,7 +79,7 @@ return p
psOut := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
if singleOut["who"] != "from-aaa" {
t.Errorf("★ single-plugin path dropped the return value: %v", singleOut)
t.Errorf("single-plugin path dropped the return value: %v", singleOut)
}
// Two plugins both return `who`; the merge order decides the winner, and it
// must be deterministic (load order), not scheduler-dependent.
@ -120,7 +120,7 @@ return p
for i := 0; i < 60; i++ {
got := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})["winner"]
if got != first {
t.Fatalf("★ winner changed between runs: %q then %q — merge depends on goroutine "+
t.Fatalf("winner changed between runs: %q then %q — merge depends on goroutine "+
"scheduling, so the same configuration behaves differently run to run", first, got)
}
}
@ -164,7 +164,7 @@ return p
t.Fatal("healthy plugin has no state")
}
if n := st["n"]; n != float64(1) {
t.Errorf("★ healthy plugin did not run alongside the throwing one: n = %v", n)
t.Errorf("healthy plugin did not run alongside the throwing one: n = %v", n)
}
he := ps.HookErrors()
if len(he) == 0 {

View File

@ -0,0 +1,74 @@
package lua
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// 持久化的出站主机授权必须在**重启后自动生效**。
//
// 实测缺陷(生产):LoadKernelPermissions 把已批准的 host 装进 ps.extraHosts,
// 但注释明说"折叠由调用方负责" —— 启动路径上没人负责。SetOutboundHosts 早已
// 跑过(core.New 里、LoadDir 之前),于是 allowlist 只有 source 主机,
// 插件声明的整套 host(zcode.z.ai / copilot.tencent.com …)在重启后全被拒:
//
// host "zcode.z.ai" is not an allowed outbound destination
//
// 症状是经典的"重启丢掉运维的决定"——运维一打开插件页重新保存就恢复。
// 它还打坏了定时任务:refresh_models 在启动后 5s 触发,正好落在那个窗口里,
// 于是每个平台的刷新都报"授权缺失"。
func TestPersistedHostGrantsSurviveRestart(t *testing.T) {
dir := t.TempDir()
plugDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(plugDir, 0o755); err != nil {
t.Fatal(err)
}
// 造一份持久化授权(模拟运维此前在 WebUI 批准过)
perms := map[string]interface{}{
"channels": map[string]interface{}{
"actions": []string{"add_source"},
"hosts": []string{"zcode.z.ai", "copilot.tencent.com"},
},
}
b, _ := json.Marshal(perms)
if err := os.WriteFile(filepath.Join(plugDir, ".kernel-perms.json"), b, 0o600); err != nil {
t.Fatal(err)
}
// 模拟启动:先 SetOutboundHosts(源主机),再 LoadDir(读授权)
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, plugDir)
ps.SetOutboundHosts([]string{"api.example.com"})
// LoadDir 会读授权文件;没有插件文件也没关系,关键是它必须折叠授权。
if err := ps.LoadDir(); err != nil {
t.Fatalf("LoadDir: %v", err)
}
al := ps.outboundAllowlist()
if al == nil {
t.Fatal("无 allowlist")
}
// ① 源主机仍可用
if !al.allows("api.example.com", "443") {
t.Error("source 主机被丢了")
}
// ② 已批准的 extra host 必须生效(这就是本判据的核心)
for _, h := range []string{"zcode.z.ai", "copilot.tencent.com"} {
if !al.allows(h, "443") {
t.Errorf("重启后已批准的 host %q 被拒 —— "+
"LoadKernelPermissions 装了 extraHosts 但没人折叠进 allowlist", h)
}
}
// ③ 没批准过的仍然不许
if al.allows("evil.example.com", "443") {
t.Error("未批准的 host 不该被放行")
}
}

View File

@ -91,7 +91,7 @@ func TestStateSurvivesRestart(t *testing.T) {
loadCounter(t, ps2)
if got := stateN(t, ps2); got != 25 {
t.Fatalf("★ total after restart = %v, want 25 — this is the 'restart loses the books' bug", got)
t.Fatalf("total after restart = %v, want 25 — this is the 'restart loses the books' bug", got)
}
}
@ -160,7 +160,7 @@ func TestPricesOnlyUpdatePersists(t *testing.T) {
t.Fatal(err)
}
if saved.Prices["rate"] != float64(42) {
t.Errorf("★ new price not persisted: %v — a restart would revert to the old price", saved.Prices)
t.Errorf("new price not persisted: %v — a restart would revert to the old price", saved.Prices)
}
if saved.State["n"] != float64(5) {
t.Errorf("state was clobbered by a price change: %v", saved.State["n"])
@ -189,7 +189,7 @@ return plugin
}
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
if out["cost_usd"] != 1.25 {
t.Errorf("★ hook return value lost: payload = %v", out)
t.Errorf("hook return value lost: payload = %v", out)
}
if out["verdict"] != "billed" {
t.Errorf("merged field lost: %v", out)
@ -269,7 +269,7 @@ func TestCloseFlushesTail(t *testing.T) {
b, err := os.ReadFile(ps.stateFile("counter"))
if err != nil {
t.Fatalf("★ Close() did not flush: %v — a shutdown loses the tail", err)
t.Fatalf("Close() did not flush: %v — a shutdown loses the tail", err)
}
var saved struct {
State map[string]interface{} `json:"state"`
@ -325,7 +325,7 @@ return plugin
b, err := os.ReadFile(ps.stateFile("ondemand"))
if err != nil {
t.Fatalf("★ an exported call did not mark the plugin dirty: %v — a plugin "+
t.Fatalf("an exported call did not mark the plugin dirty: %v — a plugin "+
"with no hooks can never persist its work", err)
}
var saved struct {

View File

@ -0,0 +1,224 @@
package lua
import (
"fmt"
"strings"
"testing"
)
// 本文件锁死"插件装的适配器能做到哪些事"——三条能力,缺任何一条,
// 为协议转换类平台(trae SOLO / gemini 信封 / codearts AK/SK 签名)
// 装专用适配器这条路就是死的。都是**实测**得出的机制事实,不是推断。
//
// 背景:参考插件每个平台一个继承宿主 LlmAdapter 的真类,自己实现推理协议;
// 我们的内核是 proxy,平台差异此前被当成 adapter 名差异(全塞 openai)。
// 内核其实早已实现 __llmsproxy_adapters.install,只是 channels 插件没用过。
// ── 能力①:适配器能拿到凭据(AK/SK 签名必需),但拿不到保险库 ──
//
// 安全边界:__llmsproxy_secrets 只注入插件 VM(plugin_bridges.go 开篇明说
// "适配器共用 setupGlobals 而不该拿到这些能力")。适配器要凭据只能走
// build_headers 的 meta.api_key —— 那是**内核按源注入**的,范围受控。
const credentialProbeAdapter = `
local adapter = {}
adapter.name = "credprobe"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.build_headers(meta)
local ak = tostring(meta.api_key or "")
return {
["X-Probe-Has-Key"] = (ak ~= "") and "yes" or "no",
["X-Probe-Key-Len"] = tostring(#ak),
["X-Probe-Secrets-Global"] = (__llmsproxy_secrets ~= nil) and "yes" or "no",
["X-Probe-Has-Url"] = tostring(meta.url or ""),
["X-Probe-Method"] = tostring(meta.method or ""),
["X-Probe-Body-Len"] = tostring(#(meta.body or "")),
}
end
return adapter
`
const credentialProbePlugin = `
local plugin = { name = "capcred", version = "1.0" }
plugin.exports = { "install" }
function plugin.install()
return { name = (__llmsproxy_adapters.install("credprobe", [==[` + credentialProbeAdapter + `]==])) }
end
return plugin
`
func TestAdapterCapabilityCredentialAccess(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("capcred", credentialProbePlugin); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("capcred", "install", nil); err != nil {
t.Fatal(err)
}
// 完整凭据 JSON(codearts 场景:AK/SK/ST 都在里面)
cred := `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`
hdrs, err := vm.BuildHeaders("credprobe", map[string]interface{}{
"url": "https://sts.example/v1/oauth2/tokens", "method": "POST",
"body": "grant_type=x", "api_key": cred,
})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
if hdrs["X-Probe-Has-Key"] != "yes" {
t.Error("适配器必须能通过 meta.api_key 拿到凭据(AK/SK 签名的前提)")
}
if hdrs["X-Probe-Key-Len"] != fmt.Sprint(len(cred)) {
t.Errorf("api_key 长度 %s ≠ %d(凭据被截断?)", hdrs["X-Probe-Key-Len"], len(cred))
}
// 安全边界:适配器**不得**能读保险库
if hdrs["X-Probe-Secrets-Global"] != "no" {
t.Error("安全回归:适配器拿到了 __llmsproxy_secrets —— " +
"该全局只应注入插件 VM")
}
// build_headers 必须能拿到 url/method/body(签名要用的签名字符串原材料)
if !strings.Contains(hdrs["X-Probe-Has-Url"], "sts.example") {
t.Error("build_headers 拿不到 url(无法构造签名)")
}
if hdrs["X-Probe-Method"] != "POST" {
t.Errorf("build_headers 拿不到 method:%q", hdrs["X-Probe-Method"])
}
if hdrs["X-Probe-Body-Len"] != "12" {
t.Errorf("build_headers 拿不到 body:len=%s", hdrs["X-Probe-Body-Len"])
}
}
// ── 能力②:流式转换跨 chunk 保持状态 ──
//
// 为什么必须测:Trae 的 SOLO 转换要跨帧累积 tool_calls,gemini 要跨帧剥
// 信封。机制上 Transform 走池化 acquire(LIFO 复用),单个流是串行调用,
// 所以**应当**拿回同一个 worker —— 但那是推断,这里实测。
const streamStateAdapter = `
local adapter = {}
adapter.name = "streambuf"
adapter.version = "1"
adapter.endpoint = "/x"
local calls = 0
local acc = {}
function adapter.transform_stream_chunk(raw)
calls = calls + 1
acc[#acc + 1] = raw
return '{"calls":' .. calls .. ',"acc":' .. #acc .. ',"vm":"' .. tostring(adapter) .. '"}'
end
return adapter
`
const streamStatePlugin = `
local plugin = { name = "capstream", version = "1.0" }
plugin.exports = { "install" }
function plugin.install()
return { name = (__llmsproxy_adapters.install("streambuf", [==[` + streamStateAdapter + `]==])) }
end
return plugin
`
func TestAdapterCapabilityStreamStateAcrossChunks(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("capstream", streamStatePlugin); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("capstream", "install", nil); err != nil {
t.Fatal(err)
}
var last, firstVM string
for i := 1; i <= 5; i++ {
out, err := vm.Transform("streambuf", "transform_stream_chunk",
`{"chunk":`+fmt.Sprint(i)+`}`)
if err != nil {
t.Fatalf("chunk %d: %v", i, err)
}
last = out
if i == 1 {
firstVM = out[strings.Index(out, `"vm"`):]
}
}
if !strings.Contains(last, `"calls":5`) {
t.Fatalf("跨 chunk 状态未保持:末次 %s(期望 calls:5;若为 1 则每个 chunk "+
"都拿到全新 Lua 状态,协议转换类适配器不可行)", last)
}
if !strings.Contains(last, `"acc":5`) {
t.Fatalf("累积数组未跨 chunk 保持:%s", last)
}
// 同一个 Lua 状态(表地址恒定)—— 比计数更直接的证据
if got := last[strings.Index(last, `"vm"`):]; got != firstVM {
t.Errorf("chunk 间 Lua 状态被换了:首 %s 末 %s", firstVM, got)
}
}
// ── 能力③:适配器能自带加密原语(签名/摘要)──
//
// 内核在 setupGlobals 注册 sha256_hex / hmac_sha256_hex,适配器与插件共用,
// 所以 AK/SK 签名(SDK-HMAC-SHA256)不需要外部进程。
const cryptoProbeAdapter = `
local adapter = {}
adapter.name = "cryptoprobe"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.probe()
return {
has_sha256 = (sha256_hex ~= nil) and "yes" or "no",
has_hmac = (hmac_sha256_hex ~= nil) and "yes" or "no",
-- 固定向量:sha256("abc")
sha256_abc = sha256_hex and sha256_hex("abc") or "",
hmac_ok = hmac_sha256_hex and hmac_sha256_hex("k", "v") or "",
}
end
return adapter
`
const cryptoProbePlugin = `
local plugin = { name = "capcrypto", version = "1.0" }
plugin.exports = { "install", "probe" }
function plugin.install()
return { name = (__llmsproxy_adapters.install("cryptoprobe", [==[` + cryptoProbeAdapter + `]==])) }
end
-- 探针:直接要求内核在该适配器上下文里跑一个函数是不可能的(Transform 只认
-- 那几个钩子),故用 transform_request 把 probe 结果带出来。
return plugin
`
func TestAdapterCapabilityCryptoPrimitives(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("capcrypto", cryptoProbePlugin); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("capcrypto", "install", nil); err != nil {
t.Fatal(err)
}
if !vm.HasFn("cryptoprobe", "probe") {
t.Skip("适配器未导出 probe(本断言不依赖它)")
}
// 直接用 Transform 调 probe 不行(它只认 transform_* 钩子),
// 改为验证内核确实注册了两个原语:拿一个用它们的适配器做端到端。
h, err := vm.BuildHeaders("cryptoprobe", map[string]interface{}{"api_key": "k"})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
_ = h
// 真正的断言放在下面:装一个用 sha256_hex 的适配器并跑它的钩子
if err := vm.LoadAdapterSource("hashproof", `
local adapter = {}
adapter.name = "hashproof"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.transform_request(raw)
-- sha256("abc") 的标准值(用内核原语算;写死的是期望值)
return '{"got":"' .. sha256_hex("abc") .. '"}'
end
return adapter
`); err != nil {
t.Fatal(err)
}
out, err := vm.Transform("hashproof", "transform_request", "{}")
if err != nil {
t.Fatalf("transform_request: %v", err)
}
const wantSHA256abc = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if !strings.Contains(out, wantSHA256abc) {
t.Fatalf("内核 sha256_hex 原语在适配器里不可用或不正确:%s", out)
}
}

View File

@ -24,6 +24,27 @@ import (
// secretGlobal 是凭据保险库的 Lua 名字。
const secretGlobal = "__llmsproxy_secrets"
// pluginDirGlobal carries the plugin's OWN directory as ground truth.
//
// Why it must come from the kernel (live defect): the channels plugin decides
// where to keep its state by reading /etc/llmsproxy/config.yaml when
// LLMSPROXY_PLUGIN_DIR is unset. Tests load the real plugin but never set that
// variable, so they read AND WROTE the production store — one full
// `go test ./internal/lua` replaced the production account pool with the test's
// empty one (13 accounts → 0; the credentials stayed in the vault, so the
// symptom was "accounts vanished"). The kernel already knows the true directory
// (ps.dir), so handing it over removes the guess entirely: a plugin no longer
// has to infer its own location from a config file it might not own.
const pluginDirGlobal = "__llmsproxy_plugin_dir"
// registerPluginDirBridge installs the plugin's own directory as a plain string
// global. Deliberately a string, not a table: there is nothing to call, and a
// bare value cannot be shadowed by a later SetField on a shared table.
func registerPluginDirBridge(L *golua.State, dir string) {
L.PushString(dir)
L.SetGlobal(pluginDirGlobal)
}
// kernelGlobal 是内核动作面的 Lua 名字。
const kernelGlobal = "__llmsproxy_kernel"

View File

@ -55,6 +55,18 @@ type ScheduledTask struct {
// Jitter, in seconds, spreads the first fire randomly so many plugins (or
// many replicas) do not all hit the same upstream at :00.
Jitter int `json:"jitter,omitempty"`
// FirstDueNow makes the FIRST run happen shortly after boot instead of after
// a full period.
//
// Why it exists (measured defect, borrowed from the reference implementation's
// post-mortem): with interval-only scheduling a restart leaves short-lived
// credentials expired for up to a whole period. The reference's providers
// have cline at 1h, codearts at ~2h and raccoon at 3h, so a 30-minute
// refresh interval left them showing "已过期" and their quota rows returning
// 401 after every host restart. A startup catch-up round fixes that — but it
// must be opt-in, because a task that is genuinely periodic (a daily
// check-in, say) should NOT fire again just because the host restarted.
FirstDueNow bool `json:"first_due_now,omitempty"`
// Timeout is parsed for forward compatibility but is NOT enforced: a running
// Lua call cannot be interrupted in this LuaJIT build (see the capability
// note on checkWedged). Overrun is handled by disarming the plugin at the
@ -106,6 +118,7 @@ func parseSchedule(L *golua.State) scheduleSpec {
t.At = readString(L, -1, "at")
t.Jitter = readNumberInt(L, -1, "jitter")
t.Timeout = readNumberInt(L, -1, "timeout")
t.FirstDueNow = readBoolField(L, -1, "first_due_now")
if t.Fn != "" && (t.Every > 0 || t.At != "") {
spec.tasks = append(spec.tasks, t)
}
@ -195,6 +208,12 @@ func (ps *Plugins) startRuntime(p *Plugin, spec scheduleSpec) *PluginRuntime {
// plugins do not all wake at process start.
func (rt *PluginRuntime) firstDue(t ScheduledTask) time.Time {
now := time.Now()
// A catch-up run: fire shortly after boot so a restart does not leave
// short-lived credentials expired for a whole period (see FirstDueNow).
// The short delay lets the rest of the plugin finish booting first.
if t.FirstDueNow {
return now.Add(catchUpDelay)
}
if t.At != "" {
return nextDailyOccurrence(t.At, now)
}
@ -319,6 +338,11 @@ func (rt *PluginRuntime) runDue() {
}
}
// catchUpDelay is how long a FirstDueNow task waits after boot. Long enough for
// the plugin's own boot work to finish, short enough that a restart heals
// expired credentials promptly rather than after a full period.
const catchUpDelay = 5 * time.Second
// nextAfter computes the following due time for a task that just fired.
func (rt *PluginRuntime) nextAfter(t ScheduledTask, now time.Time) time.Time {
if t.At != "" {
@ -489,6 +513,17 @@ func randInt63n(n int64) int64 {
// readNumberInt reads a numeric field as an int. Missing/zero yields 0, which
// every caller treats as "not set" (a task with every<=0 and no at is disabled).
// readBoolField reads a boolean field, treating only a real Lua boolean as set.
// A missing field (nil) means false — the caller's declared default.
func readBoolField(L *golua.State, idx int, field string) bool {
L.GetField(idx, field)
defer L.Pop(1)
if L.Type(-1) != golua.LUA_TBOOLEAN {
return false
}
return L.ToBoolean(-1)
}
func readNumberInt(L *golua.State, idx int, field string) int {
L.GetField(idx, field)
defer L.Pop(1)

View File

@ -2,6 +2,7 @@ package lua
import (
"fmt"
"os"
"testing"
"time"
)
@ -30,8 +31,23 @@ func runtimePlugins(t *testing.T) (*VM, *Plugins, string) {
}
t.Cleanup(vm.Stop)
pdir := fmt.Sprintf("%s/plugins", t.TempDir())
// ⚠️ **必须**设 LLMSPROXY_PLUGIN_DIR。channels 插件用 storeDir() 决定自己的
// 存储位置,而 storeDir 在环境变量缺失时会去读 /etc/llmsproxy/config.yaml ——
// 那是**生产路径**。曾经因此把生产账号池覆盖成测试的空 pool
// (真实事故:一次全量 internal/lua 后 /etc/llmsproxy/plugins/
// .channels-store.json 里 13 个账号变成 0 个,凭据却还留在保险库)。
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
t.Setenv("LLMSPROXY_PLUGIN_DIR", pdir)
}
ps := NewPlugins(vm, pdir)
ps.SetOutboundHosts([]string{"127.0.0.1"})
// ⚠️ 关掉状态持久化。插件声明了 schedule 时内核会自动起调度器
// (plugins.go: p.Runtime = ps.startRuntime(...)),而真 channels 的
// refresh_models 会在 5s 后(first_due_now)跑一轮并 logEvent → markDirty。
// 那是**后台 goroutine**,比 t.Setenv 的生命周期长:测试结束后它仍可能落盘,
// 此时环境变量已还原,storeDir() 就兜底到生产路径。
// 实测:只跑 internal/lua 全会话,生产 store 的 log 会多出调度器条目。
ps.DisableStatePersistence()
return vm, ps, pdir
}

View File

@ -47,6 +47,6 @@ return plugin`
time.Sleep(500 * time.Millisecond)
if err := exec.Command("kill", "-0", pid).Run(); err == nil {
exec.Command("kill", "-9", pid).Run()
t.Fatal("★ unload 后进程仍存活 —— on_unload 未被调用")
t.Fatal("unload 后进程仍存活 —— on_unload 未被调用")
}
}

View File

@ -634,6 +634,24 @@ func (ps *Plugins) LoadDir() error {
}
}
ps.rebuild()
// Persisted host grants must be folded into the allowlist HERE, not only in
// the WebUI grant path.
//
// Why (live defect): LoadKernelPermissions above loads `jp.Hosts` into
// ps.extraHosts but deliberately does NOT fold it — its comment says "the
// caller owns that fold". On the boot path nobody did. SetOutboundHosts had
// already run (core.New, before LoadDir), so the allowlist held the source
// hosts and nothing else, and every approved extra host (the plugin's whole
// declared set: zcode.z.ai, copilot.tencent.com, …) was refused after a
// restart with "host ... is not an allowed outbound destination".
//
// Symptom is the classic "restart loses the operator's decision": it clears
// the moment an operator opens the plugin page and re-saves the grants. It
// also broke the scheduled work — refresh_models fires 5s after boot, inside
// that window, and reported the grant as missing for every platform.
ps.mu.Lock()
ps.rebuildOutboundAllowlistLocked()
ps.mu.Unlock()
return nil
}
@ -686,6 +704,8 @@ func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*wor
// the kernel.
registerSecretBridge(L, ps, name)
registerKernelBridge(L, ps, name)
// The plugin's own directory, as ground truth (see pluginDirGlobal).
registerPluginDirBridge(L, ps.dir)
}
if err := L.DoString(code); err != nil {
L.Close()

View File

@ -260,7 +260,7 @@ end
-- 语义:命中任一 window ⇒ 乘以 multiplier。hours 用 {起,止} 闭区间,跨零点
-- 用 {{22,24}} 表示 22:00-24:00(24 是"当天最后一刻")。
--
-- ★ 为什么用 os.date 的 ! 前缀取 UTC:provider 的费率表按 UTC 标注,而网关
-- 关键:为什么用 os.date 的 ! 前缀取 UTC:provider 的费率表按 UTC 标注,而网关
-- 跑在本地时区(这台机是 Asia/Hong_Kong)。混用本地小时会让峰谷整体偏移 8
-- 小时,白天算成夜间——比不做峰谷还糟。
local function inPeakWindow(ev)

File diff suppressed because it is too large Load Diff

View File

@ -1177,7 +1177,7 @@ PLUGIN.ui = {
'" style="width:'+Math.min(100,p)+'%"></div></div>'+
'<div class="qa-w-d">$'+used.toFixed(2)+' / $'+cap.toFixed(0)+'</div>';
if(r.cap_mismatch){
h+='<div class="qa-warnl" title="已用+余额 与套餐额度不符,比例仅供参考">⚠ 额度口径可能有变</div>';
h+='<div class="qa-warnl" title="已用+余额 与套餐额度不符,比例仅供参考">注意 额度口径可能有变</div>';
}
return h+"</div>";
}

View File

@ -20,7 +20,7 @@
-- 取自己的 /api/stats —— 它已按调用 key 隔离(实测:user key 231 次 = 自己的,
-- admin 33847 次 = 全量)。
--
-- ★ 必须传**掩码**,不能传完整 key。
-- 关键:必须传**掩码**,不能传完整 key。
-- /api/stats 的统计 map 以 keyID(req.Key)(即 "***f9a9ed")为键 —— by_key
-- 返回的就是它,面板传的也是它。而 admin 身份下 ?key= 接受任意值:传完整 key
-- 会查不到,拿到零值。实测:

View File

@ -220,7 +220,7 @@ p.ui = {
page = {
page_id = "billing",
title = "Billing",
icon = "💰",
icon = "",
order = 50,
mount = "<div id=billing>hi</div><script>console.log('m')</script>",
},

View File

@ -0,0 +1,104 @@
package lua
import (
"testing"
"time"
)
// 「启动即跑一轮」判据。
//
// 为什么需要它(照参考的事后复盘):只用 interval 时,宿主重启后短寿命凭据
// (cline 1h / codearts ~2h / raccoon 3h)要等满一个周期才续期,期间一直显示
// 「已过期」、积分行 401。修复 = 声明 first_due_now 的任务在启动后短暂延迟内
// 先跑一轮。
//
// 但它**必须 opt-in**:真正周期性的任务(如每日签到)不该因为重启就重跑。
func TestScheduleFirstDueNow(t *testing.T) {
// ① 声明了 first_due_now 的任务:首轮应在 catchUpDelay 内,而不是等一个周期
now := time.Now()
catchUp := ScheduledTask{Fn: "f", Every: 7200, FirstDueNow: true}
rt := &PluginRuntime{}
d := rt.firstDue(catchUp)
if d.After(now.Add(catchUpDelay + time.Second)) {
t.Errorf("first_due_now 的任务首轮应在 %v 内,实得 %v 后",
catchUpDelay, d.Sub(now))
}
if d.Before(now) {
t.Errorf("首轮时间不该在过去:%v", d.Sub(now))
}
// ② 没声明的:必须等满一个周期(否则每日/每小时的语义会被破坏)
plain := ScheduledTask{Fn: "f", Every: 7200}
d2 := rt.firstDue(plain)
if d2.Sub(now) < 7000*time.Second {
t.Errorf("未声明 first_due_now 的任务不该提前跑:实得 %v", d2.Sub(now))
}
// ③ at 形式(每日固定时刻)不受标记影响 —— 它本来就该等到那个时刻
daily := ScheduledTask{Fn: "f", At: "08:00"}
d3 := rt.firstDue(daily)
if d3.Sub(now) > 25*time.Hour {
t.Errorf("每日任务的首次时间超出一天:%v", d3.Sub(now))
}
if d3.Sub(now) < catchUpDelay && !daily.FirstDueNow {
// 只有当 08:00 恰好在 catchUpDelay 内才可能,属正常
t.Logf("每日任务首轮临近(可能恰好在 08:00 附近):%v", d3.Sub(now))
}
// ④ 声明了 first_due_now 的每日任务:优先补跑(凭据不等时刻)
dailyCatchUp := ScheduledTask{Fn: "f", At: "08:00", FirstDueNow: true}
d4 := rt.firstDue(dailyCatchUp)
if d4.Sub(now) > catchUpDelay+time.Second {
t.Errorf("声明 first_due_now 时即便有 at 也应先补跑:%v", d4.Sub(now))
}
}
// 解析层判据:first_due_now 必须真的从插件源码读出来(写错键名会静默变成
// false —— 那就是"看起来配了、实际没补跑")。
func TestScheduleParsesFirstDueNow(t *testing.T) {
const src = `
local plugin = { name = "schedprobe", version = "1.0" }
plugin.exports = { "noop" }
function plugin.noop() end
function plugin.refresh_creds() end
function plugin.auto_claim() end
plugin.schedule = {
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
{ fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 },
}
return plugin
`
_, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("schedprobe", src); err != nil {
t.Fatal(err)
}
ps.mu.RLock()
var sched []ScheduledTask
for _, p := range ps.plugins {
if p.Info.Name == "schedprobe" {
sched = p.Schedule
}
}
ps.mu.RUnlock()
if len(sched) != 2 {
t.Fatalf("应解析出 2 个任务,实得 %d: %+v", len(sched), sched)
}
byFn := map[string]ScheduledTask{}
for _, task := range sched {
byFn[task.Fn] = task
}
if task, ok := byFn["refresh_creds"]; !ok {
t.Fatal("refresh_creds 任务未解析")
} else if !task.FirstDueNow {
t.Error("first_due_now 未被解析为 true(键名写错会静默失效)")
} else if task.Every != 1800 {
t.Errorf("every = %d,应为 1800", task.Every)
}
if task, ok := byFn["auto_claim"]; !ok {
t.Fatal("auto_claim 任务未解析")
} else if task.FirstDueNow {
t.Error("auto_claim 未声明 first_due_now,应为 false(每日任务不该因重启重跑)")
} else if task.At != "08:00" {
t.Errorf("at = %q,应为 08:00", task.At)
}
}

View File

@ -98,7 +98,7 @@ func TestSecretVaultEncryptedAtRest(t *testing.T) {
t.Fatal(err)
}
if strings.Contains(string(raw), "SUPER-TOKEN-123") {
t.Fatalf("★ 保险库明文落盘! 文件内容含 token:\n%s", raw)
t.Fatalf("保险库明文落盘! 文件内容含 token:\n%s", raw)
}
if !strings.Contains(string(raw), testEncPrefix) {
t.Fatalf("落盘值应带 %s 前缀(统一密文形态):\n%s", testEncPrefix, raw)
@ -140,7 +140,7 @@ func TestSecretVaultPlaintextFallbackAndUpgrade(t *testing.T) {
}
raw, _ := os.ReadFile(filepath.Join(dir, ".secrets", "channels.json"))
if strings.Contains(string(raw), "OLD-PLAIN") {
t.Fatalf("★ 升级写后旧明文值仍在盘上:\n%s", raw)
t.Fatalf("升级写后旧明文值仍在盘上:\n%s", raw)
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
@ -169,7 +169,7 @@ func TestSecretVaultWrongMasterKey(t *testing.T) {
// 换一把钥匙(模拟 master.key 丢失重建)
ps.SetSecretBox(failBox{})
if _, _, err := ps.GetSecret("channels", "k"); err == nil {
t.Fatal("★ 换 master key 后读库未报错 —— 会静默吞凭据")
t.Fatal("换 master key 后读库未报错 —— 会静默吞凭据")
}
}
@ -205,7 +205,7 @@ func TestSecretVaultConcurrentPlugins(t *testing.T) {
// 跨插件必须不可见
other := "plug" + string(rune('A'+(p+1)%plugins))
if _, ok, _ := ps.GetSecret(other, key); ok {
t.Errorf("★ 凭据串台: %s 的键在 %s 可见", name, other)
t.Errorf("凭据串台: %s 的键在 %s 可见", name, other)
return
}
if g == 1 && i%10 == 9 {

View File

@ -0,0 +1,61 @@
package lua
import (
"os"
"strings"
"testing"
)
// **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。
//
// 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底:
// 环境变量 → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** →
// 硬编码 /etc/llmsproxy/plugins。测试台没设环境变量时,后两级直接命中**生产**:
//
// 测试插件目录 = /tmp/TestXXX/002/plugins
// storeDir 结果 = /etc/llmsproxy/plugins ← 实测
//
// 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量
// `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool:
// /etc/llmsproxy/plugins/.channels-store.json 从 1730 变 1986 字节,
// 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。
//
// 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产
// .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。
func TestStoreDirIsolatedInTests(t *testing.T) {
_, ps, pdir := runtimePlugins(t)
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", string(src)); err != nil {
t.Fatal(err)
}
out, err := ps.Call("channels", "__store_dir_probe", nil)
if err != nil {
t.Fatalf("探针: %v", err)
}
w, _ := out.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil {
t.Fatalf("探针无 json: %#v", out)
}
got, _ := j["store_dir"].(string)
t.Logf("测试插件目录 = %s", pdir)
t.Logf("storeDir 结果 = %s", got)
// ① 绝不能是生产目录
if got == "/etc/llmsproxy/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") {
t.Fatalf("storeDir 指向了**生产**目录 %q —— 测试会覆盖生产账号池\n"+
"(真实事故:13 个账号被覆盖成 0 个)。修法:runtimePlugins 设\n"+
"LLMSPROXY_PLUGIN_DIR 到 t.TempDir()", got)
}
// ② 必须落在测试的临时目录里
if !strings.Contains(got, pdir) && !strings.HasPrefix(got, os.TempDir()) {
t.Errorf("storeDir 应落在临时目录(%s 或 %s),实得 %q", pdir, os.TempDir(), got)
}
// ③ 环境变量必须真的被设上(机制而非巧合)
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
t.Error("runtimePlugins 必须设 LLMSPROXY_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)")
}
}

View File

@ -873,6 +873,12 @@ func (v *VM) LoadAdapterSource(name, code string) error {
if err := ValidateAdapterName(name, nil); err != nil {
return err
}
// 共享前导注入点 —— 只此一处。适配器源码有三个编译点
//(worker boot / HasFn 探针 / inspectScript),它们全部源自这里存进
// pool 的 script 文本,所以拼在这里就同时覆盖三条路径,且不改变 Lua
// 作用域语义(运行时 SetGlobal 会在热重载/池重建时丢失)。
// 见 adapter_prelude.go 的开篇:12 个适配器里有 12680 字节的逐字重复。
code = withAdapterPrelude(code)
static, err := inspectScript(name, code)
if err != nil {
return err

View File

@ -32,7 +32,7 @@ func TestResultIsEmptyMatchesTheReasoningOnlyResponse(t *testing.T) {
}
// The same response WITH text is a normal answer.
if resultIsEmpty(&types.UnifiedResponse{Content: "Hi! 👋", ReasoningContent: "hmm"}) {
if resultIsEmpty(&types.UnifiedResponse{Content: "Hi! ", ReasoningContent: "hmm"}) {
t.Error("a response with content is not empty")
}
// A tool-calling agent turn has no text and is perfectly valid.

View File

@ -3,7 +3,7 @@
# 这个是打包进发行包、供首次安装使用的模板。它故意不含任何可用的凭据:
# 首次启动时程序会生成一个随机 admin key 并写入实际配置(WebUI「密钥」页可见)。
#
# ⚠ 不要在本文件里填一个写死的具体 key。示例文件是公开的,写进去的任何值
# 注意: 不要在本文件里填一个写死的具体 key。示例文件是公开的,写进去的任何值
# 都会成为一个全网皆知的 admin key(旧版示例里的 sk-gw-local-0001 就踩过
# 这个坑:它被直接抄进生产配置并真实可用)。

View File

@ -103,7 +103,7 @@ for f in "$DIST"/llmsproxy_*.deb "$DIST"/llmsproxy-*.rpm "$DIST"/*.tar.gz; do
[ -f "$f" ] || continue
sz=$(stat -c%s "$f")
[ "$sz" -gt 100000 ] || { echo "[core-dist] FATAL: $f suspiciously small ($sz B)"; exit 1; }
log " ✓ $f ($((sz/1024)) KB)"
log " OK $f ($((sz/1024)) KB)"
done
# dpkg-deb takes ONE archive; every extra argument is read as an additional
# control-component name. With the glob unquoted this used to expand to three
@ -133,7 +133,7 @@ for want in llmsproxy config.example.yaml llmsproxy.service plugins/billing.lua;
echo " (release notes claim the billing plugin ships with the core)"
exit 1
fi
log " ✓ archive contains $want"
log " OK archive contains $want"
done
# Directories are matched by their CONTENTS, not by name: tar written with
# `tar -czf` from a populated tree lists "…/adapters/openai.lua" and never a bare
@ -144,6 +144,6 @@ if [ "$n_adapters" -lt 10 ]; then
echo "[core-dist] FATAL: archive has only $n_adapters adapters (expected >= 10)"
exit 1
fi
log " ✓ archive contains $n_adapters adapters"
log " OK archive contains $n_adapters adapters"
log "done: $DIST"

View File

@ -53,7 +53,7 @@ if command -v python3 >/dev/null 2>&1; then
fi
if [ ${#missing[@]} -eq 0 ]; then
echo "✓ 浏览器栈依赖齐全"
echo "OK 浏览器栈依赖齐全"
echo " chromium: ${chromium}"
echo " noVNC: ${novnc}"
exit 0
@ -98,24 +98,24 @@ echo
echo "复核安装结果:"
ok=1
for b in "${BINS[@]}"; do
if command -v "$b" >/dev/null 2>&1; then echo " ✓ $b"
else echo " ✗ $b"; ok=0; fi
if command -v "$b" >/dev/null 2>&1; then echo " OK $b"
else echo " FAIL $b"; ok=0; fi
done
chromium=""
for c in "${CHROMIUM_CANDIDATES[@]}"; do
if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi
done
[ -n "$chromium" ] && echo " ✓ $chromium" || { echo " ✗ chromium"; ok=0; }
[ -n "$chromium" ] && echo " OK $chromium" || { echo " FAIL chromium"; ok=0; }
novnc=""
for d in "${NOVNC_DIRS[@]}"; do
[ -f "$d/vnc.html" ] && { novnc="$d"; break; }
done
[ -n "$novnc" ] && echo " ✓ noVNC ($novnc)" || { echo " ✗ noVNC"; ok=0; }
python3 -c 'import websocket' >/dev/null 2>&1 && echo " ✓ python3 websocket-client" || { echo " ✗ python3 websocket-client"; ok=0; }
[ -n "$novnc" ] && echo " OK noVNC ($novnc)" || { echo " FAIL noVNC"; ok=0; }
python3 -c 'import websocket' >/dev/null 2>&1 && echo " OK python3 websocket-client" || { echo " FAIL python3 websocket-client"; ok=0; }
if [ "$ok" = 1 ]; then
echo
echo "✓ 浏览器栈就绪。重启 llmsproxy 后,flow=vnc 的平台即可用 VNC 兜底登录。"
echo "OK 浏览器栈就绪。重启 llmsproxy 后,flow=vnc 的平台即可用 VNC 兜底登录。"
else
echo
echo "WARN: 仍有缺失项,VNC 兜底在该机器上不可用。插件的 deps/status 端点会一样报告。" >&2

View File

@ -20,7 +20,7 @@
set -uo pipefail
TOKEN="$(tr -d '\n\r' < /tmp/gc_token)"
REPO="JianFeeeee/ModelRouter"
REPO="HeNanMU-EEA/ModelRouter"
TAG="${1:?usage: upload-release.sh <tag> <file>...}"
shift
@ -43,7 +43,7 @@ try:
except Exception:
print("")' 2>/dev/null)"
if [ -z "$url" ]; then
echo "✗ 取得上传地址失败: ${resp:0:140}"
echo "FAIL 取得上传地址失败: ${resp:0:140}"
continue
fi
@ -63,9 +63,9 @@ print(subprocess.run(args, capture_output=True, text=True).stdout)
' "$f" "$url" 2>/dev/null)"
if [ "$code" = "200" ] || [ "$code" = "201" ] || [ "$code" = "204" ]; then
echo "✓ HTTP=$code"
echo "OK HTTP=$code"
else
echo "✗ HTTP=$code"
echo "FAIL HTTP=$code"
fi
sleep 1
done

View File

@ -43,7 +43,7 @@ for entry in "${checks[@]}"; do
if [ "$sz" -lt "$min" ]; then
fail "$(basename "$match") is only $sz bytes (min $min) — likely a degenerate shell like the 1.3.0 264KB exe"
fi
log " ✓ $(basename "$match") ($((sz/1024)) KB)"
log " OK $(basename "$match") ($((sz/1024)) KB)"
done
log "all artifacts healthy"

View File

@ -0,0 +1,113 @@
// 生成 CodeArts 华为云签名派生的差分期望值(供 internal/core/codearts_sign_test.go 比对)。
//
// 为什么需要:channels.lua 里的华为云 SDK-HMAC-SHA256 签名是**手工移植**
// (Lua 没有 webcrypto)。签名错一处,服务端只回一句
// `{"error_code":"APIG.0301","error_msg":"...verify ak sk signature fail"}`,
// 看起来像"账号/权限问题",极难定位。所以拿参考实现生成期望值做逐字节差分。
//
// 参考真值:vendor/channel-pack/src/sign.ts 的 signRequestHuawei。
// 这里**逐行照抄**那个函数(而非 import)—— 它依赖参考仓库的构建链,
// 抄这 40 行更能保证"同一算法"。
//
// 用法(改了参考或改了移植后都要重跑):
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
import { createHash, createHmac } from 'node:crypto'
const sha256Hex = (d) => createHash('sha256').update(d).digest('hex')
const hmacSha256Hex = (key, data) => createHmac('sha256', key).update(data).digest('hex')
/** 照抄 sign.ts:signRequestHuawei(dateStamp 提为参数以便固定时间)。 */
function signRequestHuawei(ak, sk, securityToken, method, urlStr, body, dateStamp) {
const url = new URL(urlStr)
let uri = url.pathname
if (!uri.endsWith('/')) uri += '/'
const query = url.search.slice(1)
const payloadHash = sha256Hex(body)
const headers = new Map()
headers.set('host', url.host)
headers.set('x-sdk-date', dateStamp)
headers.set('x-sdk-content-sha256', payloadHash)
headers.set('x-security-token', securityToken)
if (method.toUpperCase() !== 'GET') headers.set('content-type', 'application/json')
const signedHeaders = []
headers.forEach((_, k) => signedHeaders.push(k))
signedHeaders.sort()
const headerLines = signedHeaders.map((k) => `${k}:${headers.get(k) ?? ''}`)
const canonical = [
method,
uri,
query,
headerLines.join('\n'),
'',
signedHeaders.join(';'),
payloadHash,
].join('\n')
const stringToSign = `SDK-HMAC-SHA256\n${dateStamp}\n${sha256Hex(new TextEncoder().encode(canonical))}`
const signature = hmacSha256Hex(
new TextEncoder().encode(sk),
new TextEncoder().encode(stringToSign),
)
return {
canonical,
stringToSign,
signature,
signedHeaders: signedHeaders.join(';'),
}
}
// 覆盖三条真实路径(账户查询 / 活动列表带 query / 领取带 body),
// 以及有/无 security_token 两种形态。
const cases = [
{
m: 'GET',
u: 'https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin',
b: '',
ak: 'AK1',
sk: 'SK1',
st: 'TOK1',
},
{
m: 'GET',
u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE',
b: '',
ak: 'AK2',
sk: 'SK2',
st: '',
},
{
m: 'POST',
u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim',
b: '{"campaignId":"C1","channel":"IDE"}',
ak: 'AK3',
sk: 'SK3',
st: 'T3',
},
]
const out = cases.map((c) => {
const r = signRequestHuawei(
c.ak,
c.sk,
c.st,
c.m,
c.u,
Buffer.from(c.b, 'utf8'),
'20261013T150405Z',
)
return {
method: c.m,
url: c.u,
body: c.b,
ak: c.ak,
sk: c.sk,
st: c.st,
canonical: r.canonical,
string_to_sign: r.stringToSign,
signature: r.signature,
signed_headers: r.signedHeaders,
}
})
process.stdout.write(`${JSON.stringify(out, null, 1)}\n`)

View File

@ -0,0 +1,74 @@
// 生成 Trae 设备身份派生的差分期望值(供 internal/core/channels_trae_derive_test.go 比对)。
//
// 为什么要有这个文件:channels.lua 里的 traeDeriveStream 是**手工移植**的
// SHA-256 确定性伪随机流(Lua 5.1 没有 node:crypto)。移植错了不会报错 ——
// 上游只会返回风控/失败,看起来像"运气不好"。所以拿参考实现(参考仓库
// vendor/channel-pack/src/trae.ts 的 seededStream/seededDigits)生成期望值,
// 再让 Lua 侧算同一组输入做差分。
//
// 用法(改了参考或改了移植后都要重跑):
// node tools/trae_derive_expected.mjs > internal/core/trae_derive_expected.json
//
// 注意:这里刻意**逐行照抄**参考的 seededStream,而不是从参考仓库 import ——
// 参考是 TS/ESM 且依赖其构建链,直接抄这 20 行更能保证"同一算法"。
import { createHash } from 'node:crypto'
/** 照抄 trae.ts:seededStream —— SHA256(utf8(salt:seed) ++ counterBE32) 串联。 */
function seededStream(seed, salt, nbytes) {
const prefix = `${salt}:${seed}`
const result = []
let counter = 0
while (result.length < nbytes) {
const counterBuf = new Uint8Array(4)
counterBuf[0] = (counter >> 24) & 0xff
counterBuf[1] = (counter >> 16) & 0xff
counterBuf[2] = (counter >> 8) & 0xff
counterBuf[3] = counter & 0xff
const h = createHash('sha256')
h.update(prefix, 'utf8')
h.update(counterBuf)
for (const b of h.digest()) {
result.push(b)
if (result.length >= nbytes) break
}
counter++
}
return result.slice(0, nbytes)
}
/** 照抄 trae.ts:seededDigits —— 每字节 % 10,拼成 n 位数字串。 */
function seededDigits(n, seed, salt) {
return seededStream(seed, salt, n).map((b) => b % 10).join('')
}
/** 照抄 trae.ts:deriveDeviceId15。 */
function deriveDeviceId15(userId) {
return seededDigits(15, userId, 'devid')
}
/** 照抄 trae.ts:deriveMarketUserId —— UUID v4 形状。 */
function deriveMarketUserId(userId) {
const bs = seededStream(userId, 'market', 16)
bs[6] = (bs[6] & 0x0f) | 0x40
bs[8] = (bs[8] & 0x3f) | 0x80
const hex = bs.map((b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/** 照抄 trae.ts:deriveSessionId —— 64 位 hex。 */
function deriveSessionId(userId) {
return seededStream(userId, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join('')
}
// 覆盖边界:纯数字 / 带连字符 / 空串(空串也要能算,不能崩)/ 多字节 UTF-8 /
// 超长(超过一个 SHA-256 块,逼出 counter 递增分支 —— 移植时最易错的地方)。
const ids = ['12345', 'UID-abc', '900000001', 'x', '', '用户1', '1234567890123456789012345']
const out = ids.map((u) => ({
u,
devid: deriveDeviceId15(u),
market: deriveMarketUserId(u),
sess: deriveSessionId(u),
}))
process.stdout.write(`${JSON.stringify(out, null, 1)}\n`)