mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1be34557dd | |||
| 9e5fec08b8 | |||
| 68446fb6fe | |||
| 7e0a428c23 | |||
| 7a8d97001c | |||
| 850920155a | |||
| eaac0ef6b3 | |||
| 226493b5ae | |||
| d3a0eb6311 | |||
| cbad61a3ea | |||
| 2a2067284d | |||
| 742e2b5ed3 | |||
| 81b3661336 | |||
| 9521a35a03 | |||
| 252e3486bd | |||
| 0c56392d29 | |||
| f813ec714f | |||
| 7a6e03692d | |||
| 420379784b | |||
| 92c240cc25 | |||
| 5720e60fc9 | |||
| 42ec552c6d | |||
| 67e2ba070f | |||
| 29c87ce015 | |||
| 583976f7da | |||
| a020247627 | |||
| af8b93bd7c | |||
| 3819b00ded | |||
| 10ad4421f9 | |||
| f55d514e85 | |||
| faa3be5c37 | |||
| 8e2d384a7b | |||
| ff9b460eba | |||
| 1f54143956 | |||
| a7a0c6adee | |||
| 741edfea98 | |||
| ed1e250452 | |||
| fe9d5c6054 | |||
| 3e94047889 | |||
| 4a90099fb2 | |||
| 59229990ac | |||
| 6238265502 | |||
| 4bdde9cd61 | |||
| 70c24ba158 | |||
| c1733feeaa | |||
| 5b433b3266 | |||
| d1b7d94043 | |||
| 1586a051da | |||
| 41ff6d1be3 | |||
| 6fe6014d92 | |||
| 5ab047e48b | |||
| ec5e6dd57c | |||
| 705e079285 | |||
| 8949405746 | |||
| d86514c474 | |||
| d4ae2a1d13 | |||
| 652a035d47 | |||
| 6bec260513 | |||
| ed954b77bd | |||
| cb3ea25586 | |||
| 0232b9cd24 |
86
deploy.sh
86
deploy.sh
@ -36,8 +36,15 @@ readonly REPO_DIR="/home/program/llmsproxy"
|
||||
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
||||
readonly BIN_NAME="llmsproxy"
|
||||
|
||||
# 构建目录在 /tmp(tmpfs 更快)
|
||||
readonly TMP_DIR="/tmp/llmsproxy-deploy"
|
||||
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
|
||||
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
|
||||
# 可用 LLMSPROXY_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
|
||||
readonly BUILD_SUBDIR=".build-work"
|
||||
if [[ -n "${LLMSPROXY_BUILD_DIR:-}" ]]; then
|
||||
readonly TMP_DIR="${LLMSPROXY_BUILD_DIR}"
|
||||
else
|
||||
readonly TMP_DIR="${REPO_DIR}/${BUILD_SUBDIR}"
|
||||
fi
|
||||
# 同文件系统暂存文件(用于原子 rename)
|
||||
readonly STAGING_BIN="${TARGET_BIN}.staging"
|
||||
readonly STAGING_CONFIG="${TARGET_CONFIG}.staging"
|
||||
@ -122,14 +129,14 @@ backup_old_state() {
|
||||
log "备份旧二进制到 $BACKUP_BIN"
|
||||
if [[ -f "$TARGET_BIN" ]]; then
|
||||
cp -f "$TARGET_BIN" "$BACKUP_BIN"
|
||||
log "✓ 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
|
||||
log "OK 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
|
||||
else
|
||||
warn "旧二进制不存在,跳过备份(无回滚点)"
|
||||
fi
|
||||
|
||||
if [[ -f "$TARGET_CONFIG" ]]; then
|
||||
cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"
|
||||
log "✓ 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
log "OK 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
else
|
||||
warn "线上配置不存在: $TARGET_CONFIG(首次部署?无配置回滚点)"
|
||||
fi
|
||||
@ -142,7 +149,7 @@ build() {
|
||||
mkdir -p "$TMP_DIR/bin"
|
||||
|
||||
cd "$REPO_DIR"
|
||||
CGO_ENABLED=1 go build -tags luajit \
|
||||
CGO_ENABLED=1 GOTMPDIR="$TMP_DIR" go build -tags luajit \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$TMP_DIR/bin/$BIN_NAME" \
|
||||
@ -175,9 +182,9 @@ build() {
|
||||
)
|
||||
for c in "${checks[@]}"; do
|
||||
if strings "$NEW_BIN" | grep -qF "$c"; then
|
||||
log " ✓ 新二进制包含: $c"
|
||||
log " OK 新二进制包含: $c"
|
||||
else
|
||||
warn " ⚠ 新二进制不包含: $c(非预期但非致命)"
|
||||
warn " 注意: 新二进制不包含: $c(非预期但非致命)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
@ -207,7 +214,7 @@ atomic_replace_binary() {
|
||||
|
||||
# 验证
|
||||
NEW_SHA256=$(sha256sum "$TARGET_BIN" | awk '{print $1}')
|
||||
log "✓ 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
|
||||
log "OK 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
|
||||
}
|
||||
|
||||
# ---------- 原子替换配置 ----------
|
||||
@ -223,7 +230,7 @@ atomic_replace_config() {
|
||||
|
||||
log " rename → $TARGET_CONFIG (原子)"
|
||||
mv -f "$STAGING_CONFIG" "$TARGET_CONFIG"
|
||||
log "✓ 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
log "OK 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
|
||||
}
|
||||
|
||||
# ---------- 配置预检 ----------
|
||||
@ -236,7 +243,7 @@ verify_config() {
|
||||
|
||||
local out
|
||||
if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then
|
||||
log "✓ 配置合法"
|
||||
log "OK 配置合法"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@ -276,7 +283,7 @@ verify_master_key() {
|
||||
log " 钥匙来源: LLMS_PROXY_MASTER_KEY(环境变量)"
|
||||
# 走真实解密路径验证,而不是只检查格式
|
||||
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
|
||||
log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
return 0
|
||||
fi
|
||||
err "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:"
|
||||
@ -292,7 +299,7 @@ verify_master_key() {
|
||||
log " 钥匙文件: $key_file ($(stat -c '%A' "$key_file" 2>/dev/null || echo '?'),$(wc -c < "$key_file") 字节)"
|
||||
|
||||
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
|
||||
log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
|
||||
return 0
|
||||
fi
|
||||
err "凭据无法解密(钥匙与配置不匹配),未替换任何文件:"
|
||||
@ -388,18 +395,22 @@ 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。
|
||||
#
|
||||
# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地,
|
||||
# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在
|
||||
# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐),
|
||||
# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。
|
||||
# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器
|
||||
# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份),
|
||||
# 保留目录里运行时新增/运维自加的 .lua。
|
||||
#
|
||||
# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨
|
||||
# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。
|
||||
# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口:
|
||||
# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
|
||||
# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版,
|
||||
# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。
|
||||
#
|
||||
# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置
|
||||
# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。
|
||||
sync_plugins() {
|
||||
log "同步内置插件"
|
||||
|
||||
@ -408,24 +419,35 @@ sync_plugins() {
|
||||
|
||||
mkdir -p "$TARGET_PLUGINS"
|
||||
|
||||
# 备份将被触及的文件(只会是本次新增的那些)
|
||||
# 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动)
|
||||
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
|
||||
local added=0
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local added=0 updated=0 same=0
|
||||
for src in "$SRC_PLUGINS"/*.lua; do
|
||||
[[ -f "$src" ]] || continue
|
||||
local name; name="$(basename "$src")"
|
||||
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
|
||||
continue # 已存在:运维版本优先,不覆盖
|
||||
if cmp -s "$src" "$TARGET_PLUGINS/$name"; then
|
||||
((same++)) || true
|
||||
continue
|
||||
fi
|
||||
cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " ~ 更新内置插件: $name"
|
||||
((updated++)) || true
|
||||
else
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " + 新增内置插件: $name"
|
||||
((added++)) || true
|
||||
fi
|
||||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||||
log " + 新增内置插件: $name"
|
||||
((added++)) || true
|
||||
done
|
||||
if (( added == 0 )); then
|
||||
log " 无新增内置插件(均已存在)"
|
||||
# 没有任何改动时不必留空备份目录
|
||||
if (( updated == 0 && added == 0 )); then
|
||||
rmdir "$BACKUP_DIR" 2>/dev/null || true
|
||||
else
|
||||
log " 旧插件已备份到 $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
|
||||
@ -434,14 +456,14 @@ sync_plugins() {
|
||||
local name; name="$(basename "$src")"
|
||||
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
|
||||
done
|
||||
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)"
|
||||
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"
|
||||
}
|
||||
|
||||
# ---------- 健康检查 ----------
|
||||
@ -456,7 +478,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)"
|
||||
@ -525,7 +547,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
|
||||
|
||||
540
docs/channels-parity.md
Normal file
540
docs/channels-parity.md
Normal file
@ -0,0 +1,540 @@
|
||||
# channels 插件与参考插件的三线对齐表
|
||||
|
||||
参考仓:`Ebony-Vinyl/dsh-our-free-model`(本地克隆
|
||||
`/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model`),关键实现位于
|
||||
`vendor/channel-pack/src/`(144 个 TS 文件)。
|
||||
|
||||
命名规律(照它逐厂商对齐的三条线):
|
||||
|
||||
| 后缀 | 职责 | 我们要对齐的东西 |
|
||||
|---|---|---|
|
||||
| `X-auth.ts` / `X-oauth.ts` / `login.ts` | **登录** | 登录流程、端点、参数、凭据形状 |
|
||||
| `X-product.ts` / `models.ts` | **模型获取** | 远端端点、解析路径、兜底表 |
|
||||
| `X-adapter.ts` / `llm-adapter.ts` | **接入模型服务** | chat 端点、载荷转换、鉴权头 |
|
||||
|
||||
## 一、登录(line ①)
|
||||
|
||||
| 平台 | 参考流程 | 我们的 flow | 状态 |
|
||||
|---|---|---|---|
|
||||
| trae | OAuth 17 参数 + 设备身份派生 | callback | ✅ 逐字节对齐(含 login_trace_id / machine_id / x_device_*) |
|
||||
| minimax | device-poll | device-poll | ✅ |
|
||||
| cline | device-poll | device-poll | ✅ |
|
||||
| buddy | state-poll(**无回调**) | state-poll | ✅ |
|
||||
| gemini | OAuth + client_secret | callback | ✅(含 client_secret、localhost 主机、6 scope) |
|
||||
| lobsterai | 本地回调 + authCode 换 token | callback | ✅(含动态版本号、3 步活动领取) |
|
||||
| opencode | paste | paste | ✅(参考本身即粘贴凭据) |
|
||||
| **codearts** | **PKCE + DPoP ES256 + STS 换 AK/SK** | **callback** | ✅ **本轮修复**(此前误标 paste) |
|
||||
| qoder | device-poll + 设备身份 | device-poll | ✅(含 Cosy-MachineToken/Type 成对) |
|
||||
| zcode | init-poll | init-poll | ✅ |
|
||||
| loomy | 微信扫码 poll | wechat-poll | ✅ |
|
||||
| raccoon | 二维码 poll | qr-poll | ✅ |
|
||||
|
||||
### codearts 登录(本轮修复的重点)
|
||||
|
||||
此前标成 `flow="paste"`(手填凭据)是**错的**。参考 `login.ts` / `oauth.ts`
|
||||
默认走**新式 IAM OAuth**:
|
||||
|
||||
1. 起本地回调服务器,端口须 **≥10000**(`MIN_CALLBACK_PORT`,低于此 portal 拒绝),
|
||||
路径 `/oauth/callback`
|
||||
2. 打开 `https://codearts.huaweicloud.com/portal/authorize`,参数逐项:
|
||||
`theme=2`、`locale=zh-cn`、`uri_scheme=codearts-agent`、`client_id=codearts-agent`、
|
||||
`code_challenge_method=**SHA-256**`(不是 RFC 的 S256 —— 错值会让 portal
|
||||
**回退旧 ticket 流程**,看起来"也能用"但不是 OAuth)、`ticket_id`、
|
||||
`plugin-name=snap_AIIDE`、`plugin-version=5.2.0`;
|
||||
**不得**含 `auth_callback_url`(会被判异常并回退)
|
||||
3. 回调收到 `code` 后,带 **DPoP 证明**向
|
||||
`https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens` 换
|
||||
`credentials{access_key_id, secret_access_key, security_token, expiration}`
|
||||
+ `refresh_token`
|
||||
4. 凭据持久化须含续期材料:`refresh_token` / `code_verifier` / `dpop_private_key_jwk`
|
||||
|
||||
DPoP 细节:ES256 JWS,`header={alg,typ:dpop+jwt,jwk}`、
|
||||
`payload={htm,htu,iat,jti}`;**签名是 raw r||s 64 字节**,而 openssl 输出 DER
|
||||
——必须转换格式,否则服务端验签失败而字符串看起来完全正常。
|
||||
|
||||
终态判定只认 `invalid_grant` / `ErrorCode 含 ExpiredRefreshToken`;
|
||||
**不认** `InvalidDPoPHeader`(那是一次请求层面的拒绝:时钟偏差、proof 重放、
|
||||
网关抖动都会触发,当终态会把完好账号一步标死)。
|
||||
|
||||
旧 ticket 流程(`CODEARTS_LOGIN_BASE` + `/snap-manager/v1/login/ticket` 轮询)
|
||||
保留作回退。
|
||||
|
||||
## 二、模型获取(line ②)
|
||||
|
||||
远端优先、兜底补位的机制已具备(`models_remote` + `effectiveModels`)。
|
||||
本轮把**兜底表**逐平台补成参考全量:
|
||||
|
||||
| 平台 | 参考来源 | 兜底数 | 状态 |
|
||||
|---|---|---|---|
|
||||
| trae | `TRAE_FALLBACK_MODELS` | 28 可见 + 4 hidden | ✅(hidden 单列 `models_hidden`) |
|
||||
| minimax | `GET /mavis/api/v1/models?region=cn&buildEnv=prod` | 4 | ✅ |
|
||||
| cline | `GET /api/v1/models` + `recommended-models` | 3 | ✅ |
|
||||
| buddy | (无独立表) | 7 | ✅ |
|
||||
| gemini | — | 2 | ✅ |
|
||||
| lobsterai | `GET /api/models/available`(**须带 model_flag**) | 19 | ✅ |
|
||||
| opencode | `GET /v1/models` | 14 | ✅(含 `models_free`) |
|
||||
| codearts | opengw `gateway/config` + `/v1/model/builtin` | 3 | ⚠️ 见下 |
|
||||
| qoder | `/api/v2/model/list`(需 WASM 签名) | 19 | ✅(含 CN 独有 q37fmodel/gm51model) |
|
||||
| zcode | `GET /api/v1/client/configs` | 2 | ✅ |
|
||||
| loomy | `GET /api/v1/models`(取 `type==='chat'`) | 8 | ✅ |
|
||||
| raccoon | 远端 + 兜底 | 6 | ✅ |
|
||||
|
||||
**trae 的 `isHidden` 语义**:`browser_use_subagent` / `explore_sub_agent_v13` /
|
||||
`explore_sub_agent_v2` / `summary` 是内部子 agent,不进用户可选列表。
|
||||
`model_list` 已按此过滤(`models_hidden`)。
|
||||
|
||||
**opencode 的 `isFree`**:参考明确标注 7 个模型匿名可用、其余需付费 key。
|
||||
此前 `model_list` 把 `isFree` 硬编码为 false,等于把官方免费模型标成付费。
|
||||
|
||||
### codearts 模型获取(未闭环)
|
||||
|
||||
参考 `models.ts` 从两个端点拉:
|
||||
- `OPENGW_GATEWAY_CONFIG_URL`(`opengw.developer.huaweicloud.com/api/v1/gateway/config`)
|
||||
→ `result.models`,这是 **benefit(免费额度)模型**权威来源
|
||||
- `SNAP_MODEL_BUILTIN_URL`(`/v1/model/builtin`)→ `builtinModels`,常规模型
|
||||
|
||||
两者都要 **AK/SK 的 SDK-HMAC-SHA256 签名**。另有 `normalizeModelId`
|
||||
(去尾部 `-NNNN` 日期版本)与 `isCodeArtsBenefitModel`(决定 chat 是否带
|
||||
`maas_type: benefit`,不带则 404 `model is not registered`)。
|
||||
**这部分尚未实现**——我们的 codearts 目前只有 3 个硬编码模型名。
|
||||
|
||||
## 三、接入模型服务(line ③)
|
||||
|
||||
**本轮发现的最严重问题**:源注册时 `base_url + adapter.endpoint` 拼出的
|
||||
chat URL **全部缺路径前缀**,11 个平台里 11 个都不对。已逐个修正:
|
||||
|
||||
| 平台 | 正确 chat 端点 | 修正前 |
|
||||
|---|---|---|
|
||||
| trae | `…/api/agent/v3/llm_utils_chat` | `…/chat/completions` |
|
||||
| cline | `api.cline.bot/api/v1/chat/completions` | `api.cline.bot/chat/completions` |
|
||||
| lobsterai | `…/api/proxy/v1/chat/completions` | `…/chat/completions` |
|
||||
| qoder | `api2-v2.qoder.sh/model/v1/chat/completions` | `…/chat/completions` |
|
||||
| buddy | `copilot.tencent.com/v2/chat/completions` | `…/chat/completions` |
|
||||
| codearts | `…/api/v2/chat/completions` | `…/chat/completions` |
|
||||
| opencode | `opencode.ai/zen/v1/chat/completions` | `opencode.ai/zen/chat/completions` |
|
||||
| raccoon | `…/api/web/llm/v2/chat/completions` | `…/chat/completions` |
|
||||
| loomy | `loomy.xunfei.cn/chat/completions` | `loomy.xunfei.cn` |
|
||||
| zcode | `zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages` | `zcode.z.ai/v1/messages` |
|
||||
| gemini | `/v1internal:generateContent`(私有动词) | `/v1beta/models/{model}:generateContent`(公开,凭据不匹配) |
|
||||
|
||||
### ✅ 已闭环:三个私有协议平台改用**插件自装适配器**
|
||||
|
||||
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
|
||||
`LlmAdapter` 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。
|
||||
内核其实**早已实现** `__llmsproxy_adapters.install`(含归属校验/冲突仲裁/
|
||||
测试),但 channels 插件对该全局的引用数是 **0**。现已第一次真正使用:
|
||||
|
||||
| 平台 | 自装适配器 | 解决的协议差异 |
|
||||
|---|---|---|
|
||||
| codearts | `codearts` | 华为云 SDK-HMAC-SHA256 **AK/SK 签名**(canonical/string-to-sign 逐字节) |
|
||||
| trae | `channels-trae` | **SOLO 载荷**:config_name 双字段、function 通道、tool_calls→function_call、parameters→JSON 字符串、tool_choice 归一化 |
|
||||
| gemini | `channels-gemini` | 私有 `/v1internal:` **信封** + 身份五头 + 字母序序列化 + 响应剥一层 |
|
||||
|
||||
命名说明:trae 用 `channels-trae` 而非 `trae`,因为内置 `trae.lua` 服务的是
|
||||
**运维自建的 trae-local-api 反代**(`127.0.0.1:19900`,生产 config.yaml 在用),
|
||||
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)——
|
||||
判据里专门断言内置 `trae` 未被顶掉。
|
||||
|
||||
**新增能力:`credential_shape`**。签名平台需要**完整凭据 JSON**(适配器自己
|
||||
解 AK/SK),其余平台交裸 access token。此前 `syncAccountSource` 一律用
|
||||
`credentialOf` 取 `access_token`,于是 codearts 凭据(只有
|
||||
`access_key_id`/`secret_access_key`/`security_token`)取不到 token →
|
||||
**静默 return、不出源**。现已按 `plat.credential_shape` 区分。
|
||||
|
||||
**另一处静默失败已修**:`syncAccountSource` 拿到内核 `add_source` 的失败
|
||||
(权限未授、形状不符)时不记日志,表现为"账号加了但不出源",排查零线索。
|
||||
现补失败日志(区分内核 err 与 `json.ok==false`)。
|
||||
|
||||
### 仍未闭环:gemini 的 sigstore(明确不做)
|
||||
|
||||
参考 `gemini-sigstore.ts`(268 行)持久化 tool-call 签名,供跨请求回填。
|
||||
我们推理走**无状态适配器**,插件侧缓存会成为死代码。签名缺失时上游会拒,
|
||||
适配器如实透传错误。理由与结论已记在此处,避免反复讨论。
|
||||
|
||||
## 四、明确不做的(附理由)
|
||||
|
||||
| 参考模块 | 行数 | 为什么不做 |
|
||||
|---|---|---|
|
||||
| `gemini-sigstore.ts` | 268 | 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔ |
|
||||
| `qoder-wasm.ts` | 1442 | 需随插件分发 298KB wasm 二进制;B 类,待立项 |
|
||||
| `zcode-captcha.ts` | 2212 | 阿里云滑块,需真实浏览器/验证码链路;B 类 |
|
||||
| `captcha-*.ts` | 1223 | 同上 |
|
||||
|
||||
## 五、本机实测踩到的坑(都已修)
|
||||
|
||||
1. **`xxd` 不存在** —— `command -v xxd` 为空,用它取十六进制会**静默得到空串**。
|
||||
改用 `od -An -tx1`。
|
||||
2. **`openssl ec -text` 会截断 32 字节 priv**(实测只给 30 字节)→ `d` 必须用
|
||||
`openssl asn1parse` 的 `[HEX DUMP]`;`tail -c 32` 拿到的是 SEC1 结构里
|
||||
**公钥的 y**,不是 `d`。
|
||||
3. **Lua local 前向引用**:`local function` 只从定义处向后可见。`hexToBin` /
|
||||
`b64url` / `b64urlRaw` 被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块
|
||||
**之前**(本文件累计踩过 6 次)。
|
||||
4. **`min_port` 参数读到 `argv[2]`** 与 `out_path` 冲突 —— 回调脚本的第 5 个
|
||||
参数才是 minPort。
|
||||
5. **Lua 双引号串里的 `\1`** 是转义坑(`sed 's/…/…/\1/'` 会报
|
||||
invalid escape sequence)→ 改用 `awk -F': ' '{print $NF}'`。
|
||||
|
||||
---
|
||||
|
||||
# 为什么参考插件是"正常"的:架构差异(2026-10-13 查证)
|
||||
|
||||
用户问「为什么他们就正常」。查完参考仓的架构后,答案是**三层结构差异**,
|
||||
不是某一个端点的笔误。
|
||||
|
||||
## 参考的架构:一个平台 = 一个适配器类
|
||||
|
||||
参考依赖宿主 SDK 的 seam:`import { LlmAdapter } from '@deepseek-ai/dsh-llm'`。
|
||||
每个平台有一个**继承基类的真类**,同时拥有登录、模型目录、推理三件事:
|
||||
|
||||
| 文件 | 类 | 干什么 |
|
||||
|---|---|---|
|
||||
| `trae-adapter.ts` | `TraeAdapter` | SOLO 协议转换(`config_name`/`function`,非 OpenAI 兼容) |
|
||||
| `llm-adapter.ts` | `CodeArtsAdapter` | 华为云 AK/SK 签名 + `maas_type: benefit` 判定 |
|
||||
| `zcode-adapter.ts` | `ZcodeAdapter` | Anthropic Messages 协议(非 OpenAI) |
|
||||
| `qoder-adapter.ts` | `QoderAdapter` | WASM 加密端点 + 信封剥离 |
|
||||
| `gemini-adapter.ts` | `GeminiAdapter` | `/v1internal:` 私有动词 + 字母序信封 |
|
||||
|
||||
注册方式是 `registerAdapterIdempotent(ctx.llm, [PROVIDER], adapter)`
|
||||
(`llm-adapter.ts:2080`)。
|
||||
|
||||
**但它不重复**:共享层是 `src/openai-compat.ts`(1196 行)——
|
||||
`serializeMessages`(消息序列化)、`consumeOpenAiSse`(SSE 消费)、
|
||||
`httpErrorCode` / `isTransportError`(错误归类)、`userContentParts`(多模态)。
|
||||
12 个 adapter 里有 8 个 `import from './openai-compat'`。
|
||||
|
||||
即:**公共协议逻辑一份,平台差异各自一个类**。
|
||||
|
||||
## 我们的架构:内核是 proxy,插件只注册源
|
||||
|
||||
- 适配器是 Lua 文件(12 个),通过 4 个钩子参与:
|
||||
`transform_request` / `transform_response` / `transform_stream_chunk` /
|
||||
`transform_error`,外加 `build_headers`(动态签名,见下)。
|
||||
- 声明面:`endpoint` / `endpoint_stream` / `ingress` / `headers`。
|
||||
- 插件通过 `__llmsproxy_kernel.call("add_source", {source={...}})` 把平台
|
||||
注册为上游源,`source.adapter` 引用适配器名。
|
||||
|
||||
## 关键发现:机制早就有,但 channels 插件从没用过
|
||||
|
||||
内核**完整实现了**插件自装适配器:
|
||||
|
||||
```
|
||||
internal/lua/plugin_adapter_bridge.go
|
||||
func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error)
|
||||
registerAdapterBridge → Lua 全局表 __llmsproxy_adapters.install / .update / .remove
|
||||
```
|
||||
|
||||
- 有归属校验(`adapterOwnership`:只能改自己装的)
|
||||
- 有冲突仲裁(影子内置/他人所有 → 挂起等运维裁决,不是硬拒)
|
||||
- 有测试证明可用:`TestPluginInstallsAdapterFromLua`(PASS)
|
||||
|
||||
**但 `grep -c '__llmsproxy_adapters' internal/lua/plugins/channels.lua` = 0。**
|
||||
channels 插件一个适配器都没装过 —— 它只会注册 `adapter = "openai"` 的源。
|
||||
这就是"为什么我们不正常"的直接原因:**平台协议差异被当成适配器名差异**。
|
||||
|
||||
## 我们还缺的一层:共享协议模块
|
||||
|
||||
参考有 `openai-compat.ts`;我们没有对应物。`openai.lua` 自己写明
|
||||
「Lua adapters have no shared prelude」,`safe_tool_id` 在 `openai.lua` 与
|
||||
`anthropic.lua` 里**各复制了一份**(各 6 处引用)。
|
||||
|
||||
所以正确做法是两层:
|
||||
1. 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类),
|
||||
或内核在加载时注入 —— 消掉复制粘贴
|
||||
2. channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、
|
||||
codearts AK/SK 签名)**装自己的适配器**,而不是硬塞给 `openai`
|
||||
|
||||
### 所需的适配器能力(crypto + 出站 + 流式)
|
||||
|
||||
已核实内核提供:
|
||||
- `sha256_hex` / `hmac_sha256_hex`(`vm.go` setupGlobals)
|
||||
- `build_headers(meta)` 收 `{url, method, body, api_key, timestamp,
|
||||
client_session, source{name,meta}}` —— CodeArts 的 AK/SK 签名用它
|
||||
(与参考 `signRequestHuawei` 同构,但 AK/SK 要从 `api_key` 里解出)
|
||||
- `transform_stream_chunk` 逐 chunk 调用(`provider.go:1295`)
|
||||
- 插件有完整 OS 能力(`io.popen`/`os.execute`)→ 可用 openssl 做 EC 签名
|
||||
|
||||
### 未验证的风险
|
||||
|
||||
- 适配器 VM 能否访问 `__llmsproxy_secrets`(AK/SK 要解出 SK 才能签名)
|
||||
- 流式转换能否跨 chunk 保持状态(trae 的 SSE 需要跨帧累积 tool_calls)
|
||||
|
||||
这两条是下一步落地前必须先探的。
|
||||
|
||||
---
|
||||
|
||||
# 插件自装适配器:机制与判据(2026-10-13)
|
||||
|
||||
## 机制(内核早已具备,本轮才开始使用)
|
||||
|
||||
```
|
||||
internal/lua/plugin_adapter_bridge.go
|
||||
InstallAdapter(plugin, name, code) # 含归属校验、冲突仲裁(影子内置 → 挂起等裁决)
|
||||
registerAdapterBridge → Lua 全局 __llmsproxy_adapters.install / .update / .remove
|
||||
```
|
||||
注册点在 `bootPluginState`,**先于** `DoString(code)` —— 所以插件在脚本执行期
|
||||
就能装适配器。适配器装在**适配器 VM**(与插件 VM 分离,各自独立)。
|
||||
|
||||
## 三条能力(实测,不是推断)
|
||||
|
||||
`internal/lua/plugin_adapter_capability_test.go` 锁死:
|
||||
|
||||
| 能力 | 结论 | 为什么关键 |
|
||||
|---|---|---|
|
||||
| 拿凭据 | 适配器**能**经 `build_headers` 的 `meta.api_key` 拿到(`url`/`method`/`body` 也在) | AK/SK 签名的前提;同时**验不到** `__llmsproxy_secrets`(只注入插件 VM,安全边界完好) |
|
||||
| 流式状态 | `transform_stream_chunk` **跨 chunk 保持同一 Lua 状态**(表地址恒定) | SOLO 跨帧累积 tool_calls、gemini 跨帧剥信封的前提 |
|
||||
| 加密原语 | 适配器可用内核 `sha256_hex` / `hmac_sha256_hex`(用标准向量校验) | 签名不需要外部进程 |
|
||||
|
||||
## 踩过的坑(都是"静默失败"类,值得记)
|
||||
|
||||
1. **`function` 是 Lua 关键字** —— 写 `body.function = x` 是**语法错误**
|
||||
(`'<name>' expected near 'function'`),必须 `body["function"]`。
|
||||
插件脚本本身能编译通过,**只在适配器 `load()` 时才暴露**。
|
||||
2. **Lua 长字符串里不能有字面 NUL 字节** —— 报
|
||||
`unfinished long string near '<eof>'`,且**报在文件末尾**,完全指不到现场。
|
||||
3. **本 Lua 构建不支持按位异或 `~`**(`unexpected symbol near '~'`)。
|
||||
4. **装载失败只回"源码无法编译"、不给行号** —— 三个自装适配器现统一加
|
||||
**本地预编译护栏**(先 `load` 一次拿行号)。上面三个坑都是靠它定位的。
|
||||
5. **抽公共代码要抽最全的实现**。前导重构时我先抽了 `transform_error` 的
|
||||
最小版(只认 `{error:{message}}`),打挂 6 个既有测试 —— 最小版丢掉了
|
||||
HTML 错误页、裸文本、扁平 `{code,message}`、FastAPI `{detail}`。
|
||||
抽最小实现是**静默降级**:不报错,只是少认几种错误体。
|
||||
6. **判据必须能看见真实函数的返回值**。我一度用 `kernelTestCore` 裸加载插件
|
||||
(没授 `add_source` 权限)测源注册,源一直不出;直到把内核原始返回
|
||||
(`kernel action add_source is not granted to plugin channels`)带出来才
|
||||
定位。**黑盒断言只在失败时告诉你"不对",不告诉你"为什么"**。
|
||||
|
||||
## 判据清单
|
||||
|
||||
| 文件 | 锁死什么 |
|
||||
|---|---|
|
||||
| `internal/lua/plugin_adapter_capability_test.go` | 三条能力(凭据/流式状态/加密原语) |
|
||||
| `internal/lua/adapter_prelude_test.go` | 前导注入 + 语义值正确 + 旧适配器行为不变 |
|
||||
| `internal/lua/adapter_prelude_equivalence_test.go` | 重构前后逐字节等价(含反向断言) |
|
||||
| `internal/lua/channels_trae_solo_test.go` | SOLO 7 条规则 + 三处反向断言 |
|
||||
| `internal/lua/channels_gemini_adapter_test.go` | 身份五头逐值 + 不得发 google key/Accept + 字母序 + 响应剥信封 |
|
||||
| `internal/core/channels_codearts_adapter_test.go` | 装机/归属 + **Go stdlib 独立复算 HMAC 验签** |
|
||||
| `internal/core/channels_private_adapter_wiring_test.go` | 三平台的**源真的引用了**自装适配器 |
|
||||
|
||||
---
|
||||
|
||||
# 生产实测:不可用的真相(2026-10-14)
|
||||
|
||||
## 症状与根因
|
||||
|
||||
用户报"完全不可用"。实测(`/api/plugins/http/channels/account/list` 逐平台):
|
||||
|
||||
| 平台 | 账号数 | **有凭据的** |
|
||||
|---|---|---|
|
||||
| buddy | 2 | **1** |
|
||||
| trae | 3 | 0 |
|
||||
| minimax | 1 | 0 |
|
||||
| lobsterai | 1 | 0 |
|
||||
| codearts | 5 | 0 |
|
||||
| 其余 7 平台 | 0 | 0 |
|
||||
|
||||
**13 个账号里只有 1 个真有凭据**,其余全是**占位壳子**(`has_credential: false`)。
|
||||
占位账号不是"已登录",它只是为「先建账号行、登录后填凭据」这个两步流程预留的
|
||||
行。此前的判断只看「12 平台 0 个 manual」,那是**错的判据** —— manual 是
|
||||
flow 类型,不是"登录成功"。
|
||||
|
||||
## 排除项(都实测过,不是猜)
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| 服务状态 | `active`,`NRestarts=0` |
|
||||
| 部署一致性 | 4 个插件 sha256 与仓库**全部一致**;关键标记(installCodeartsAdapter / installTraeAdapter / installGeminiAdapter / sorted_json / sign_parts)全部在位 |
|
||||
| **推理链路** | `POST /v1/chat/completions` 实测返回 `"OK"`(走 buddy 源、deepseek-v4.1-flash)—— **网关本身完全可用** |
|
||||
| 登录机制 | minimax 真实返回 `user_code=T49S-RC7G` + 验证地址;trae 起了服务端浏览器 + 回环回调 |
|
||||
| 设备码轮询 | 5 次全部 `pending`(未授权时**不误判**为失败或成功 —— 这是正确行为) |
|
||||
|
||||
## 结论:不是代码坏了,是**授权没完成**
|
||||
|
||||
剩下的 12 个平台都需要**人工动作**才能走完登录:
|
||||
|
||||
- **device-poll**(minimax / cline / qoder):把 `user_code` 拿到
|
||||
`account.minimax.cn/oauth-authorize` 之类的**官方页面**输入
|
||||
- **callback**(trae / gemini / lobsterai):在 **VNC 窗口**里点授权
|
||||
(回环回调只能由本机解析,远程浏览器永远接不住)
|
||||
- **qr-poll / wechat-poll**(raccoon / loomy):扫码
|
||||
- **init-poll**(zcode):授权页 + 可能的验证码
|
||||
|
||||
这些**按设计就必须人工**,assistant 无法独立端到端完成。
|
||||
|
||||
## B 类:实测结论
|
||||
|
||||
| 项 | 行数 | 结论 |
|
||||
|---|---|---|
|
||||
| qoder-wasm + envelope | 706 + 226 | **二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。没有 `qoder-auth-wasm.wasm` 无法实现;拿到才有意义 |
|
||||
| zcode-captcha + carrier | 2119 + 93 + 280 + 206 | 阿里云滑块。且**登录本身不需要它** —— captcha 只用于**签到/领取**环节(`zcode-auth.ts:22-23`:claim 时每个 plan 单独 mint captcha)。登录闭环不受阻,受阻的是自动领取 |
|
||||
| captcha-pool | 280 | 同上,服务于领取环节的验证码供给 |
|
||||
|
||||
**修正判断**:B 类不是"登录闭环的拦路虎"。zcode 的登录(`/api/v1/oauth/cli/init`
|
||||
→ poll)不需要验证码;captcha 只在**自动领取**时出现。所以 B 类的真实价值是
|
||||
"让领取全自动",而不是"让平台可用"。
|
||||
|
||||
---
|
||||
|
||||
# 真实数据事故:测试覆盖了生产账号池(2026-10-14)
|
||||
|
||||
## 症状
|
||||
|
||||
用户报「完全不可用」。逐平台 `account/list` 后发现:**13 个账号里只有 1 个有凭据**,
|
||||
其余是占位壳子;再查发现**账号池被清空了**(`pool` 全为空 `{}`),
|
||||
而保险库 `.secrets/channels.json` 里的凭据**还在**。
|
||||
症状正是「账号凭空消失」。
|
||||
|
||||
## 根因(实测输出,非推断)
|
||||
|
||||
channels 插件用 `storeDir()` 决定存储位置,它有多级兜底:
|
||||
|
||||
```
|
||||
env LLMSPROXY_PLUGIN_DIR → settings.store_dir → 读 /etc/llmsproxy/config.yaml 的 plugin_dir → 硬编码
|
||||
```
|
||||
|
||||
测试台没设 `LLMSPROXY_PLUGIN_DIR`,**后两级直接命中生产**:
|
||||
|
||||
```
|
||||
测试插件目录 = /tmp/TestXXX/002/plugins
|
||||
storeDir 结果 = /etc/llmsproxy/plugins ← 探针实测输出
|
||||
```
|
||||
|
||||
于是测试加载真 `channels.lua` 时**读的是生产存储、写的也是生产存储**。
|
||||
一次全量 `go test ./internal/lua` 就把
|
||||
`.channels-store.json` 从 1730 覆盖成 1986 字节、账号池清空(inode 都换了)。
|
||||
|
||||
只有 `internal/lua` 复现,`internal/core` 不复现 —— 因为 core 的测试台
|
||||
(`kernelTestCore`)设了 `LLMSPROXY_PLUGIN_DIR`。**这是隔离做得不一致**的代价。
|
||||
|
||||
同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。
|
||||
|
||||
## 为什么 `DisableStatePersistence` 不够
|
||||
|
||||
第一版修复给 `runtimePlugins` 关了持久化,**仍然复现**:插件声明了 `schedule`
|
||||
时内核会自动起调度器(`plugins.go: p.Runtime = ps.startRuntime(...)`),
|
||||
真 channels 的 `refresh_models` 会在 5s 后(first_due_now)跑一轮并
|
||||
`logEvent → markDirty`。那是**后台 goroutine,比 `t.Setenv` 的生命周期长** ——
|
||||
测试结束后它仍可能落盘,此时环境变量已还原。
|
||||
|
||||
## 修法(两层,第二层才是根治)
|
||||
|
||||
1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜):
|
||||
新增 `__llmsproxy_plugin_dir` 全局(`registerPluginDirBridge`),
|
||||
`storeDir()` 优先用它。**即使没有环境变量也不会落到生产路径** ——
|
||||
实测:撤掉 env 后 `storeDir` 仍落在临时目录。
|
||||
2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()`。
|
||||
|
||||
## 判据 + 变异验证
|
||||
|
||||
`internal/lua/store_dir_isolation_test.go`:
|
||||
`storeDir` 绝不能是 `/etc/llmsproxy` 前缀、必须落在临时目录、env 必须真的被设。
|
||||
**变异验证**:把隔离设成生产路径 → 判据变红并打印「storeDir 指向了**生产**目录」;
|
||||
恢复 → 变绿。
|
||||
|
||||
全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。
|
||||
|
||||
## ⚠️ 修复过程中我自己踩的坑
|
||||
|
||||
恢复账号时我把**保险库文件里的密文**当成明文,喂给 `account/add` 的
|
||||
`credential` —— 保险库 `loadSecretsLocked` **会在读取时解密**,于是那份凭据被
|
||||
**二次加密**(1971 → 2965 字符),该账号的推理开始返回空
|
||||
(`codebuddy_*` 源失效,而非池路径 `channels:buddy` 正常)。
|
||||
|
||||
教训:**凭据只能由登录/续期路径写入,绝不要手工把密文塞回 API**。
|
||||
要复用既有凭据,应走「让插件自己读保险库」的路径,或者重新登录一次。
|
||||
|
||||
---
|
||||
|
||||
# 逐模块对照的当前状态(2026-10-14 更新)
|
||||
|
||||
穷尽对照参考仓 **143 个 .ts 模块 / 77,720 行**。约定:
|
||||
✅ 已对齐 · ⚠️ 部分/简化 · ❌ 未实现 · ⛔ 明确不做
|
||||
|
||||
## 本轮(第 2 批)新补的
|
||||
|
||||
| 模块 | 参考规模 | 补了什么 / 为什么 |
|
||||
|---|---|---|
|
||||
| `dead-model-store` | 708 | 上游下架的模型仍显示且标"免费",一点就 404。**判据只用阳性 404 证据,绝不拿远端目录反推**(buddy 那类平台目录残缺,反推会删可用模型) |
|
||||
| `remote-catalog-gate` | 114 | 参考点名"不能省":目录拉不到会放大成「一次请求 × 每个模型」,首屏卡死 |
|
||||
| `buddy-balance-rank` + `loomy-balance-rank` + 两个 selector | ~1000 | **余额分档选号**:优先用「15 天内到期」的号,否则积分白白作废;锁定时只剩永久的号等同不可用 |
|
||||
| `account-probe` | 568 | `account_test` **此前一个网络请求都不发**(恒返回 ok:true/ms:1)—— 假阳性 |
|
||||
| `model-gate` | 218 | 源改**串行 + 按模型节流**(并发发起必然浪费一个配额;串行不保证间隔) |
|
||||
| `ttl-cache` / `serial-queue` | 291 | 抽成通用件,消掉三处各自手写的缓存/冷却逻辑 |
|
||||
| `opencode-capability` | 423 | isFree 改由 models.dev 的 `cost` 权威决定(不再是硬编码快照)。**受内核 4 MiB 上限约束不可用**,已如实降级为可选增强 |
|
||||
|
||||
另修 3 个**静默失败**:`account_test` 不发请求、业务级错误(HTTP 200 + body 带
|
||||
error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了)。
|
||||
|
||||
## 仍未实现(按影响面排序)
|
||||
|
||||
| 模块 | 行数 | 为什么不做的理由 / 阻塞 |
|
||||
|---|---|---|
|
||||
| `zcode-captcha` + 6 个 captcha-* | ~3345 | 阿里云滑块。**登录不需要它** —— captcha 只在签到/领取环节(`zcode-auth.ts:22-23`)。真实价值是"让领取全自动"而非"让平台可用" |
|
||||
| `qoder-wasm` + `qoder-envelope` | 932 | **wasm 二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。拿不到就无法实现 |
|
||||
| `zcode-identity` / `transport` / `diagnostics` | 1104 | 设备身份 + 传输层 + 错误诊断。与 captcha 链路耦合 |
|
||||
| `account-pool` / `channel-pack-store` | 1912 | 我们已自建(形态不同,非缺口) |
|
||||
| `opencode-proxy` / `opencode-rpc` | 530 | 多出口代理(NAT 后多机共享出口时按 IP 限流才需要) |
|
||||
| `message-shape` | 146 | **不适用**:那是 DSH 宿主的消息形状迁移(0.1.5→0.1.7)。我们的适配器直接透传 OpenAI 形状,无此问题 |
|
||||
| `loop-recovery` | 346 | **前提不存在**:它做的是"循环守卫中止后自动续跑",而我们的内核**没有循环守卫** |
|
||||
| `reasoning-ladder` | 182 | 需要内核支持 `efforts` 字段(`Model` 结构只有 id/priority/kind/meta)。属内核改动,按纪律不做 |
|
||||
| `badge-*` / `image-budget` / `ttl-cache`(已补) | ~500 | 徽标订阅、图片预算 —— 宿主 UI 特性 |
|
||||
| `raccoon-login-page` / `raccoon-qr` | 1124 | 登录页与二维码的完整实现(我们走简化版) |
|
||||
| `zcode-carrier-page` | 93 | 内部载体页(依赖 DSH Desktop 的 webview guest,我们没有那个壳) |
|
||||
| `cline-*` 4 个 / `gemini-project` / `gemini-sigstore` / `loomy-sign`(部分) | ~900 | 平台细节与签名持久化 |
|
||||
|
||||
## 一句话结论
|
||||
|
||||
**平台登录/凭据/源注册这条主线已打通且逐平台对齐**(12 平台零 manual、三平台
|
||||
自装适配器、SOLO/信封/AK-SK 签名逐字节验证)。
|
||||
**未实现的部分集中在两类**:(1) 需要**真实浏览器/验证码**的领取全自动
|
||||
(captcha 系 ~3300 行);(2) **二进制缺失**(qoder wasm)。
|
||||
其余是宿主 UI 特性或前提不存在的模块。
|
||||
|
||||
---
|
||||
|
||||
# 第三批(2026-10-14 晚)—— 逐模块对照的**新发现**
|
||||
|
||||
## ★ 本轮新补的三个真缺陷
|
||||
|
||||
### ① 自动领取每天静默漏领(`credits.ts` / `auto-checkin.ts`)
|
||||
活动刷新时刻**各平台不同**(Qoder 每日 10:00 UTC+8),而我们只在 08:00 跑一次
|
||||
⇒ 上午看到昨天那条 `CLAIMED`、报 `already-claimed` 并记账 ⇒ 当天再不去领。
|
||||
**修法照参考**:不配"时刻表"(必然漂移),让**产出方声明 `covers_today`**,
|
||||
记账方只数"有几条能证明今天已处理"。两层都要:频率提高 + `first_due_now`。
|
||||
|
||||
### ② 凭据续期此前是假的(`refresh.ts` / `expiry-sync.ts`)
|
||||
`account_refresh` **一个换 token 的请求都不发**却恒返回 `ok:true`
|
||||
⇒ `refresh_creds` 打「已续期 N 个」而什么都没续 —— **日志在说谎**。
|
||||
这是本轮第三次遇到同形态(前两次:`account_test` 恒 ok、业务级错误被当成功)。
|
||||
|
||||
### ③ `isFree` 与声明自相矛盾(`cline-models.ts`)
|
||||
cline 的免费模型声明在 `models` 里 ⇒ 被标成收费。
|
||||
★ 关键认识:**两个参考用的是不同机制** —— cline 用远端 `free[]`,
|
||||
opencode 用 models.dev 的 `cost`。混成一条 OR 链会与既有判据正面冲突。
|
||||
⇒ 加 `free_source` 由**平台声明**权威来源。
|
||||
|
||||
## 插件自装适配器(第 3、4 个)
|
||||
| 适配器 | 解决的问题 |
|
||||
|---|---|
|
||||
| `channels-qoder` | 错误信封**双层编码**;`10605`(排队) 与 `110`(额度耗尽) 语义**相反**,必须分开说(参考实测:110 原被归成通用服务端错误 → 白重试 5 次 ≈15.5 秒) |
|
||||
| `channels-cline` | 429 **无 retry-after 头**、等待时长写在英文句子里(`Try again in 19h 39m`)。解析不出 ⇒ 内核按 30 秒短冷却 ⇒ **每 30 秒猛敲上游**(比参考的「永远 60 分钟」更糟) |
|
||||
|
||||
现共 4 个:`channels-trae` / `channels-gemini` / `channels-qoder` / `channels-cline`。
|
||||
|
||||
## 对照后判定「不适用」的(附理由,不是"没做")
|
||||
|
||||
| 模块 | 为什么不需要 |
|
||||
|---|---|
|
||||
| `account-pool` 并发写竞态 | 我们的落盘是**同步** tmp+rename(Lua 无异步),每次写序列化**当前** state。参考的缺陷是"异步后端 + 共享可变快照"的产物。**已加判据钉住**(已删账号不复活、新增不丢失、内存/磁盘不分叉) |
|
||||
| `gemini-messages` SSE 末帧丢失 | 我们的 `bufio.Scanner` 对无尾换行的最后一行**照样产出**(已实测自证)。参考那是在 `while(!done)` 里按行处理所致 |
|
||||
| `cline-rate-limit` 的 30 分钟策略冷却 | 我们**每账号一个源**(`channels-<plat>-<acct>`)⇒ 某账号被拦只冷却它自己,下个请求自然换源。参考是"一个池里换号",才需要长冷却挡住同一轮里的连续重撞 |
|
||||
| `message-shape` | DSH 宿主的消息形状迁移;我们直接透传 OpenAI 形状 |
|
||||
| `loop-recovery` | 前提不存在(我们的内核没有循环守卫) |
|
||||
| `reasoning-ladder` | 需要内核支持 `efforts` 字段(属内核改动,按纪律不做) |
|
||||
| `retry-after.ts` | 参考那份实现**自己就是死代码**(grep 无消费者);我们的固定 30s 冷却有明确理由(429 是"太快"不是"坏了",走指数退避会让额度充沛的源被锁 30 分钟) |
|
||||
| `image-budget` | 实际缩放委托给宿主的附件服务,我们无对应物 |
|
||||
|
||||
## 运行环境的一条教训(不是代码缺陷)
|
||||
全量 `go test` 曾出现 `TestConcurrentPluginAdapterUpdates` **挂 59 分钟**、
|
||||
看着像死锁。单独跑 **0.058s 通过** ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建
|
||||
缓存占满 100%**。教训:构建/测试挂住时先 `df -h /tmp`(`/` 空闲不代表 `/tmp` 够)。
|
||||
@ -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...]],
|
||||
},
|
||||
@ -377,7 +377,48 @@ plugin.http = {
|
||||
路径,未认证会跳登录页。
|
||||
- 未声明的路径不会因为落在允许命名空间而被放行——不在 handler 表里就是 404。
|
||||
|
||||
### 5.6 在运行时安装/更新适配器(token 轮换)
|
||||
### 5.6 前缀代理:把插件自起的服务挂到网关同端口
|
||||
|
||||
前节的自定义挂载点只能回答**由插件计算出来的内容**(`{status, headers, body/json}`)。
|
||||
若插件自己起了一个 TCP/HTTP 服务(典型:服务端浏览器栈 + noVNC),客户端就需要
|
||||
**字节级转发**,包括 **WebSocket 升级** —— 前者做不到。
|
||||
|
||||
为此声明 `prefix = true`,handler 每次请求返回一个**回环目标**:
|
||||
|
||||
```lua
|
||||
plugin.http = {
|
||||
{ path = "myplugin/proxy/:session", method = "GET", prefix = true,
|
||||
roles = { "admin" }, handler = "proxy" },
|
||||
}
|
||||
|
||||
function plugin.proxy(req)
|
||||
-- 每次请求都问一遍:会话没了/过期了就返回 404,地址自然失效
|
||||
if not sessionAlive(req.params.session) then
|
||||
return { status = 404, json = { error = "no such session" } }
|
||||
end
|
||||
return { proxy = "http://127.0.0.1:60100" }
|
||||
end
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **只能返回 `{ proxy = <目标> }`**,没有 `status` / `body`。写了 `status` 会被忽略。
|
||||
- 目标必须是 **带显式端口的字面回环 IP**(`http://127.0.0.1:60100`)。
|
||||
拒绝 `https://`、`localhost` 这类主机名、无端口、带 userinfo/path/query 的地址。
|
||||
理由:主机名可能被 hosts / DNS / 代理环境改写;通用能力只接受确定性地址。
|
||||
- 路由匹配同前缀下的**所有子路径**,`:param` 仍照常捕获,`req.path` 是完整
|
||||
请求路径,子路径由插件自己从 `req.path` 里切。
|
||||
- **鉴权先于代理**:`roles` / `keys` / `public` 语义与普通端点一致。
|
||||
- **不转发网关凭据**:`Authorization`、`Cookie`、`api_key` 都不会传给后端服务。
|
||||
- **拒绝跨站**:`Origin` 与网关自身 host 不一致返回 403。WebSocket 由浏览器
|
||||
发起且自动带 Cookie,否则第三方网页可借管理员会话开隧道。
|
||||
- **撤销点唯一**:handler 不再返回目标(或返回 404)即失效,内核不缓存目标。
|
||||
|
||||
客户端拿到的 URL 是**相对网关口**的绝对路径(如
|
||||
`/api/plugins/http/myplugin/proxy/<id>/vnc.html`),因此远程访问、HTTPS 入口、
|
||||
Cookie 鉴权都自然生效,无需暴露额外端口。
|
||||
|
||||
### 5.7 在运行时安装/更新适配器(token 轮换)
|
||||
|
||||
插件可以在运行时安装、改写**自己的**适配器 —— 这是"带自动轮换的认证"的实现
|
||||
方式:拿着新 token 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器
|
||||
@ -428,7 +469,7 @@ end
|
||||
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**)
|
||||
都用这同一把互斥锁串行化。
|
||||
|
||||
⚠️ 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
|
||||
注意: 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
|
||||
`plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了,
|
||||
对实际流量完全无效。
|
||||
|
||||
@ -449,7 +490,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 +577,7 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
|
||||
{ "prices": { ... }, "count_failures": true }
|
||||
```
|
||||
|
||||
### 7.5 ⚠️ 计费插件只报表,不执法
|
||||
### 7.5 注意: 计费插件只报表,不执法
|
||||
|
||||
**网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。**
|
||||
本插件不参与任何路由或配额决策。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
102
internal/core/channels_account_adopt_test.go
Normal file
102
internal/core/channels_account_adopt_test.go
Normal file
@ -0,0 +1,102 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// account_adopt:把升级前的**非池**凭据接管进账号池。
|
||||
//
|
||||
// 为什么需要它:v2.0.x 之前登录只写一条 `channels:<plat>`,引入账号池后凭据落在
|
||||
// `channels:<plat>:<aid>`。升级后老凭据仍在保险库、但池里没有账号引用它 ——
|
||||
// 表现为"账号消失了"(数据其实还在)。让用户重新登录是**可见的额外代价**。
|
||||
//
|
||||
// ⚠️ 本判据同时钉住一条操作纪律:**接管必须走插件的保险库 API**(读出的是明文,
|
||||
// 写回时插件自己加密)。绝不要把保险库文件里的密文当明文喂给 API ——
|
||||
// 保险库读取时会解密,那样会把凭据**二次加密**(实测踩过:1971 → 2965 字符,
|
||||
// 该账号推理开始返回空)。
|
||||
func TestAccountAdoptLegacyCredential(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
const legacy = `{"access_token":"LEGACY-TOK","refresh_token":"RT","nickname":"老账号"}`
|
||||
|
||||
// 造一条"旧版"非池凭据(模拟升级前留下的)
|
||||
if _, err := ps.Call("channels", "__set_legacy_cred_probe", map[string]interface{}{
|
||||
"platform": "buddy", "credential": legacy,
|
||||
}); err != nil {
|
||||
t.Fatalf("造旧凭据: %v", err)
|
||||
}
|
||||
|
||||
// 接管进一个新池账号
|
||||
m, err := ps.Call("channels", "account_adopt", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "adopted1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_adopt: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["ok"] != true {
|
||||
t.Fatalf("接管失败: %#v", m)
|
||||
}
|
||||
|
||||
// ① 池里必须出现该账号,且**有凭据**
|
||||
lm, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
|
||||
lj, _ := lm.(map[string]interface{})["json"].(map[string]interface{})
|
||||
found := false
|
||||
for _, a := range asArrInterface(lj["accounts"]) {
|
||||
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
|
||||
found = true
|
||||
if am["has_credential"] != true {
|
||||
t.Errorf("接管后应 has_credential=true,实得 %v", am["has_credential"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("接管后账号未出现在池里: %#v", lj["accounts"])
|
||||
}
|
||||
|
||||
// ② 凭据必须是**原文**(未被二次加密)——这是本判据的核心
|
||||
out, err := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||||
"ref": "channels:buddy:adopted1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("读凭据: %v", err)
|
||||
}
|
||||
rj, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
got, _ := rj["credential"].(string)
|
||||
if got != legacy {
|
||||
t.Errorf("接管后的凭据应与原文逐字节相同(不得二次加密)\n 期望: %s\n 实得: %s",
|
||||
legacy, got)
|
||||
}
|
||||
if rj["len"] != nil {
|
||||
if n, _ := rj["len"].(float64); int(n) != len(legacy) {
|
||||
t.Errorf("凭据长度 %v,应为 %d(长度变化说明被二次加密)", n, len(legacy))
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 补齐了展示字段(昵称从凭据里取)
|
||||
for _, a := range asArrInterface(lj["accounts"]) {
|
||||
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
|
||||
if am["nickname"] != "老账号" {
|
||||
t.Errorf("昵称应从凭据补齐为「老账号」,实得 %v", am["nickname"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 没有旧凭据时必须如实报错(不能假装成功)
|
||||
_, _ = ps.Call("channels", "__clear_all_creds_probe", nil)
|
||||
m2, _ := ps.Call("channels", "account_adopt", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "adopted2",
|
||||
})
|
||||
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j2["ok"] == true {
|
||||
t.Error("没有旧凭据时应如实失败,不能假装成功")
|
||||
}
|
||||
}
|
||||
|
||||
// asArrInterface 宽松取数组(插件桥回传的可能是 []interface{})。
|
||||
func asArrInterface(v interface{}) []interface{} {
|
||||
if a, ok := v.([]interface{}); ok {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
}
|
||||
105
internal/core/channels_account_probe_test.go
Normal file
105
internal/core/channels_account_probe_test.go
Normal file
@ -0,0 +1,105 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// `account_test` 必须**真打一次上游**。
|
||||
//
|
||||
// 真实缺陷:此前它一个网络请求都不发 —— 只 syncAccountSource 后查内核源列表里
|
||||
// 有没有那条记录,就返回 {ok:true, ms:1}。于是"测试账号"永远绿,包括凭据已失效、
|
||||
// 被限流、上游宕机的情况。这正是「看着能用、一用就错」的假阳性,也正是用户报
|
||||
// 「完全不可用」时最容易误导人的那种信号。
|
||||
//
|
||||
// 判据把三条都钉住:① 真发请求 ② 上游失败时必须 ok=false ③ 失败要区分
|
||||
// "凭据失效"(需重新登录)与"暂时性失败"(可重试)—— 只说"失败"用户不知道该做什么。
|
||||
func TestAccountTestReallyProbesUpstream(t *testing.T) {
|
||||
var hits int32
|
||||
fail := false
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if fail {
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant: token expired"}`))
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"data": map[string]interface{}{"Response": map[string]interface{}{
|
||||
"Data": map[string]interface{}{"Accounts": []map[string]interface{}{
|
||||
{"Status": 1, "CycleCapacityRemain": 100},
|
||||
}},
|
||||
}},
|
||||
})
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"balance": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "probeacct",
|
||||
"credential": `{"access_token":"TOK"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
|
||||
// ① 正常路径:必须真的打了上游
|
||||
atomic.StoreInt32(&hits, 0)
|
||||
m, err := ps.Call("channels", "account_test", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "probeacct",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_test: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j["ok"] != true {
|
||||
t.Fatalf("正常凭据应 ok=true: %#v", j)
|
||||
}
|
||||
if j["probed"] != true {
|
||||
t.Errorf("必须标记 probed=true 并真的发请求;实得 probed=%v", j["probed"])
|
||||
}
|
||||
if n := atomic.LoadInt32(&hits); n == 0 {
|
||||
t.Fatal("account_test 未向上游发任何请求 —— 这就是「看着能用」的假阳性")
|
||||
}
|
||||
|
||||
// ② 凭据失效:必须 ok=false,且归类为 auth(给出可操作提示)
|
||||
fail = true
|
||||
m2, _ := ps.Call("channels", "account_test", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "probeacct",
|
||||
})
|
||||
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j2["ok"] != false {
|
||||
t.Errorf("凭据失效时必须 ok=false,实得 %v", j2["ok"])
|
||||
}
|
||||
if j2["kind"] != "auth" {
|
||||
t.Errorf("应归类为 auth(需重新登录),实得 %v", j2["kind"])
|
||||
}
|
||||
if s, _ := j2["action"].(string); s == "" {
|
||||
t.Error("失败必须给出可操作提示(否则用户不知道要重新登录还是等一会)")
|
||||
}
|
||||
|
||||
// ③ 无凭据的账号:如实报错,不假装成功
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "naked",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add(无凭据): %v", err)
|
||||
}
|
||||
m3, _ := ps.Call("channels", "account_test", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "naked",
|
||||
})
|
||||
j3, _ := m3.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j3["ok"] != false {
|
||||
t.Errorf("无凭据时必须 ok=false,实得 %v", j3["ok"])
|
||||
}
|
||||
}
|
||||
182
internal/core/channels_authorize_test.go
Normal file
182
internal/core/channels_authorize_test.go
Normal file
@ -0,0 +1,182 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// callback 平台的授权 URL 是最容易"看起来对、实际不可用"的地方:URL 能拼出来、
|
||||
// 页面能打开,但参数名/编码/主机错一处,上游就拒绝授权。
|
||||
//
|
||||
// 判据照参考实现(trae-oauth.ts / gemini-oauth.ts / lobsterai-oauth.ts)逐条锁定:
|
||||
// - Trae 参数名必须是 auth_callback_url(不是 redirect_uri)
|
||||
// - Gemini redirect_uri 主机必须是 localhost(不是 127.0.0.1),token 交换要一致
|
||||
// - LobsterAI 的 login 在 **hash 段**(/portal#/login),三个参数用 ? 起
|
||||
// - redirect_uri 必须百分号编码(含 :// 与 :)
|
||||
func TestCallbackAuthorizeUrls(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := func(t *testing.T, plat string) (string, map[string]interface{}) {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s start_login: %v", plat, err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
u, _ := j["url"].(string)
|
||||
return u, j
|
||||
}
|
||||
|
||||
t.Run("行1 trae 用 auth_callback_url 而非 redirect_uri", func(t *testing.T) {
|
||||
u, _ := start(t, "trae")
|
||||
if u == "" {
|
||||
t.Fatal("trae 未给出授权 URL")
|
||||
}
|
||||
if !strings.Contains(u, "auth_callback_url=") {
|
||||
t.Fatalf("trae 必须用 auth_callback_url(参考注释点名:错名则登录必失败): %s", u)
|
||||
}
|
||||
if strings.Contains(u, "redirect_uri=") {
|
||||
t.Fatalf("trae 不该有 redirect_uri 参数: %s", u)
|
||||
}
|
||||
for _, want := range []string{"client_id=en1oxy7wnw8j9n", "auth_type=local", "login_channel=native_ide"} {
|
||||
if !strings.Contains(u, want) {
|
||||
t.Fatalf("trae 缺参数 %s: %s", want, u)
|
||||
}
|
||||
}
|
||||
// 关键:不能再出现 "…/authorization&redirect_uri=" 这种坏的拼接
|
||||
if strings.Contains(u, "authorization&") {
|
||||
t.Fatalf("授权 URL 首参分隔符必须是 ? 而不是 &: %s", u)
|
||||
}
|
||||
// 参考 trae-oauth.ts 把参数集列为「一个都不能少」(早期只发 5 个参数,
|
||||
// 登录页**永远停在授权中**,既不跳转也不回传)。逐条锁定:
|
||||
// login_trace_id 是回调反查 pending 的唯一凭据;machine/device 身份除外
|
||||
// 还可能被风控拦;x_* 是客户端形态伪装。
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
t.Fatalf("trae 授权 URL 无法解析: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
for _, k := range []string{
|
||||
"login_version", "auth_from", "login_channel", "plugin_version",
|
||||
"auth_type", "client_id", "redirect", "login_trace_id",
|
||||
"auth_callback_url", "machine_id", "device_id",
|
||||
"x_device_id", "x_machine_id", "x_device_brand", "x_device_type",
|
||||
"x_os_version", "x_app_version", "x_app_type",
|
||||
} {
|
||||
if q.Get(k) == "" {
|
||||
t.Fatalf("trae 缺参数 %s(参考列为必需): %s", k, u)
|
||||
}
|
||||
}
|
||||
// 设备身份是 hex32,trace_id 是它们拼接后的末 16 位
|
||||
m, d := q.Get("machine_id"), q.Get("device_id")
|
||||
if len(m) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(m) {
|
||||
t.Fatalf("machine_id 应为 hex32: %q", m)
|
||||
}
|
||||
if len(d) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d) {
|
||||
t.Fatalf("device_id 应为 hex32: %q", d)
|
||||
}
|
||||
if m == d {
|
||||
t.Fatalf("machine_id 与 device_id 不应相同(同源会削弱设备身份)")
|
||||
}
|
||||
joined := m + d
|
||||
if got := q.Get("login_trace_id"); got != joined[len(joined)-16:] {
|
||||
t.Fatalf("login_trace_id 应为 (machine_id+device_id) 末16位,实得 %q", got)
|
||||
}
|
||||
// 同一平台两次登录必须得到不同设备身份(否则多账号共用设备,
|
||||
// 会被上游以「该设备已签到」拦截)
|
||||
u2, _ := start(t, "trae")
|
||||
q2, _ := url.Parse(u2)
|
||||
if q2.Query().Get("device_id") == d {
|
||||
t.Fatalf("两次登录的 device_id 应不同(每账号独立设备身份)")
|
||||
}
|
||||
// Trae 注册的回调路径是 /authorize(不是插件的 /api/plugins/http 挂载点):
|
||||
// 回环地址由「打开授权页的那台机器」解析,所以必须是本机 loopback 接收器。
|
||||
if cb := q.Get("auth_callback_url"); cb != "" {
|
||||
if !strings.HasSuffix(cb, "/authorize") {
|
||||
t.Fatalf("trae auth_callback_url 必须以注册路径 /authorize 结尾,实得 %q", cb)
|
||||
}
|
||||
if !strings.HasPrefix(cb, "http://127.0.0.1:") {
|
||||
t.Fatalf("trae auth_callback_url 应是 127.0.0.1 回环地址,实得 %q", cb)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 gemini 主机是 localhost 且参数不重复", func(t *testing.T) {
|
||||
u, _ := start(t, "gemini")
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
t.Fatalf("授权 URL 无法解析: %v", err)
|
||||
}
|
||||
ru := parsed.Query().Get("redirect_uri")
|
||||
if !strings.HasPrefix(ru, "http://localhost:") {
|
||||
t.Fatalf("gemini redirect_uri 主机必须是 localhost(参考注释明确): %q", ru)
|
||||
}
|
||||
// 路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH)——
|
||||
// 换成别的路径就是 redirect_uri_mismatch。
|
||||
if !strings.HasSuffix(ru, "/oauth-callback") {
|
||||
t.Fatalf("gemini redirect_uri 路径必须是 /oauth-callback,实得 %q", ru)
|
||||
}
|
||||
for _, k := range []string{"client_id", "response_type", "scope", "state"} {
|
||||
if parsed.Query().Get(k) == "" {
|
||||
t.Fatalf("gemini 缺参数 %s: %s", k, u)
|
||||
}
|
||||
}
|
||||
// 缺 access_type=offline 拿不到 refresh_token;prompt=consent 保证每次都发。
|
||||
if got := parsed.Query().Get("access_type"); got != "offline" {
|
||||
t.Fatalf("gemini access_type 应为 offline(否则无 refresh_token),实得 %q", got)
|
||||
}
|
||||
if got := parsed.Query().Get("prompt"); got != "consent" {
|
||||
t.Fatalf("gemini prompt 应为 consent,实得 %q", got)
|
||||
}
|
||||
// 参数不得重复(通用分支曾再追加一次 redirect_uri)
|
||||
if n := strings.Count(u, "redirect_uri="); n != 1 {
|
||||
t.Fatalf("redirect_uri 出现 %d 次(应恰好 1 次): %s", n, u)
|
||||
}
|
||||
if n := strings.Count(u, "client_id="); n != 1 {
|
||||
t.Fatalf("client_id 出现 %d 次: %s", n, u)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 lobsterai 的 login 在 hash 段且参数编码", func(t *testing.T) {
|
||||
u, _ := start(t, "lobsterai")
|
||||
if !strings.Contains(u, "/portal#/login?") {
|
||||
t.Fatalf("lobsterai 授权页必须是 /portal#/login?…(login 在 hash 段): %s", u)
|
||||
}
|
||||
if strings.Contains(u, "/login&") {
|
||||
t.Fatalf("首参分隔符必须是 ? 而不是 &: %s", u)
|
||||
}
|
||||
if !strings.Contains(u, "source=electron") {
|
||||
t.Fatalf("lobsterai 缺 source=electron: %s", u)
|
||||
}
|
||||
// redirect_uri 必须编码(含 :// 与 :),否则登录页校验失败
|
||||
if !strings.Contains(u, "redirect_uri=http%3A%2F%2F") {
|
||||
t.Fatalf("lobsterai redirect_uri 必须百分号编码: %s", u)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 端口用本机监听值而非硬编码8081", func(t *testing.T) {
|
||||
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
|
||||
u, _ := start(t, plat)
|
||||
if strings.Contains(u, "127.0.0.1:8081") && plat != "trae" {
|
||||
t.Fatalf("%s 不应硬编码 8081: %s", plat, u)
|
||||
}
|
||||
if strings.Contains(u, "{port}") {
|
||||
t.Fatalf("%s 的 {port} 占位未被替换: %s", plat, u)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
111
internal/core/channels_autoclaim_cover_test.go
Normal file
111
internal/core/channels_autoclaim_cover_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 自动领取的「今天已处理」记账(照参考 coversToday / shouldMarkToday)。
|
||||
//
|
||||
// 真实缺陷(参考 2026-10-02 定位,每天静默漏 100 积分):Qoder 的活动
|
||||
// **每日 10:00(UTC+8)才刷新**,而自动签到在上午跑 —— 上午看到的是**昨天**
|
||||
// 那条 CLAIMED,返回 `already-claimed`。若拿它记账,当天 10 点刷新后就再也
|
||||
// 不去领。
|
||||
//
|
||||
// 修法不靠「给每个渠道配刷新时刻表」(必然漂移),而是让**产出方自己回答**
|
||||
// 「我这条结果覆盖的是不是今天这一轮」(covers_today),记账方只数
|
||||
// 「有几条能证明今天已被处理」。
|
||||
func TestAutoClaimCountsCoversTodayNotKind(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// ① Qoder 在刷新前必须声明 covers_today=false(否则这条链就白修)
|
||||
out, err := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
|
||||
"refreshed": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j == nil || j["kind"] == nil {
|
||||
t.Fatalf("探针应返回 kind,实得 %#v", out)
|
||||
}
|
||||
if j["covers_today"] != false {
|
||||
t.Errorf("刷新前必须声明 covers_today=false(这正是漏领的根因),实得 %v", j["covers_today"])
|
||||
}
|
||||
if j["kind"] == "already-claimed" {
|
||||
t.Error("刷新前**绝不能**报 already-claimed(那会被记账成「今天已处理」)")
|
||||
}
|
||||
|
||||
// ② 刷新后:正常报已领,且 covers_today=true
|
||||
out2, _ := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
|
||||
"refreshed": true,
|
||||
})
|
||||
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j2["covers_today"] != true {
|
||||
t.Errorf("刷新后应声明 covers_today=true,实得 %v", j2["covers_today"])
|
||||
}
|
||||
|
||||
// ③ 记账判据必须**只看 covers_today**,不能用 kind 反推
|
||||
//(`already-claimed` 天然有歧义 —— 可能就是昨天那一轮的痕迹)
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
cov interface{}
|
||||
want bool
|
||||
}{
|
||||
{"刷新前的 already-claimed 不得算已处理", "already-claimed", false, false},
|
||||
{"刷新后的 already-claimed 算已处理", "already-claimed", true, true},
|
||||
{"success 算已处理", "success", true, true},
|
||||
{"covers_today 缺省为 true", "claimed", nil, true},
|
||||
{"inactive 不算已处理", "inactive", true, false},
|
||||
{"failed 不算已处理", "failed", true, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
o, err := ps.Call("channels", "__claim_covers_probe", map[string]interface{}{
|
||||
"kind": c.kind, "covers_today": c.cov,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
oj, _ := o.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if got, _ := oj["covers"].(bool); got != c.want {
|
||||
t.Errorf("%s: 应 %v,实得 %v", c.name, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调度频率:一天只跑一次会让「刷新时刻晚于执行时刻」的平台整天漏领。
|
||||
func TestAutoClaimScheduledMoreThanOnceDaily(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
out, err := ps.Call("channels", "__schedule_probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
every, _ := j["auto_claim_every"].(float64)
|
||||
if every <= 0 {
|
||||
t.Fatalf("找不到 auto_claim 的调度间隔,实得 %v", j)
|
||||
}
|
||||
// 必须显著小于一天:活动刷新时刻各平台不同(Qoder 是每日 10:00 UTC+8),
|
||||
// 一天只跑一次就必然有平台漏领。
|
||||
if every >= 86400 {
|
||||
t.Errorf("auto_claim 间隔应远小于一天(否则刷新晚的平台整天漏领),实得 %.0f 秒", every)
|
||||
}
|
||||
if j["first_due_now"] != true {
|
||||
t.Error("auto_claim 应 first_due_now(重启立即补跑,不等一个周期)")
|
||||
}
|
||||
// 已覆盖今天的平台必须靠记账跳过,否则会重复打上游
|
||||
if !strings.Contains(toString(j["has_coverage_key"]), "true") {
|
||||
t.Error("需要「今天已覆盖」的持久化记账键")
|
||||
}
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
if b, ok := v.(bool); ok {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
159
internal/core/channels_autoclaim_test.go
Normal file
159
internal/core/channels_autoclaim_test.go
Normal file
@ -0,0 +1,159 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 自动签到的**能力表**必须与参考真值一致 —— 这张表是 auto_claim 的唯一依据,
|
||||
// 错了就是"静默少领"或"每次调用都报自相矛盾的错"。
|
||||
//
|
||||
// 实测踩到的三处偏差(本轮修正):
|
||||
//
|
||||
// buddy 我们写 claim=true,但 buddy 既无 checkin 又无 claim_fn →
|
||||
// 每次调用返回"不支持每日签到(平台未声明 checkin)"(表说支持、
|
||||
// 函数说不支持)。参考里 buddy **没有** claim。
|
||||
// raccoon 我们声明空表 → 白丢一个可领渠道。参考有:桌面端登录奖励
|
||||
// POST /api/web/desktop/v1/login/points/grant(每号一次)。
|
||||
// zcode 有签到,但每次领取现场产阿里云 captcha(headful Chromium,
|
||||
// 按设备限流 150 次/小时)→ 参考把它列进 auto-checkin 的**排除表**。
|
||||
// 自动跳过,手动仍可领。
|
||||
func TestAutoClaimCapabilityTableMatchesReference(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
_ = c
|
||||
|
||||
// 参考真值(逐个 grep 参考仓的 claim 导出确认)
|
||||
wantClaim := map[string]bool{
|
||||
"trae": true, "minimax": true, "lobsterai": true, "qoder": true,
|
||||
"loomy": true, "raccoon": true, "codearts": true,
|
||||
"cline": false, "gemini": false, "zcode": true, "buddy": false,
|
||||
}
|
||||
// 自动签到必须跳过的(领取有代价)
|
||||
wantAutoExclude := map[string]bool{"zcode": true}
|
||||
|
||||
for plat, want := range wantClaim {
|
||||
t.Run("claim_"+plat, func(t *testing.T) {
|
||||
got := creditsCapability(t, ps, plat)
|
||||
if got == nil {
|
||||
t.Fatalf("%s 无能力声明", plat)
|
||||
}
|
||||
if has := got["claim"] == true; has != want {
|
||||
t.Errorf("%s claim = %v,参考为 %v", plat, has, want)
|
||||
}
|
||||
if got, ok := got["auto_exclude"].(bool); ok != wantAutoExclude[plat] {
|
||||
t.Errorf("%s auto_exclude = %v,应为 %v", plat, got, wantAutoExclude[plat])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 关键一致性:声称 claim=true 的平台**必须**真的可领
|
||||
//(有 checkin 表或有 claim_fn)—— 否则就是 buddy 那种自相矛盾。
|
||||
for plat, want := range wantClaim {
|
||||
if !want {
|
||||
continue
|
||||
}
|
||||
m, err := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": plat})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", plat, err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil {
|
||||
t.Fatalf("%s 无返回", plat)
|
||||
}
|
||||
if j["capable"] != true {
|
||||
t.Errorf("%s 声称 claim=true 但不可领(%v)—— 会出现「表说支持、"+
|
||||
"函数说不支持」的自相矛盾", plat, j["reason"])
|
||||
}
|
||||
}
|
||||
// 反向:不声称 claim 的平台必须被判为不可领
|
||||
m, _ := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": "buddy"})
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j != nil && j["capable"] == true {
|
||||
t.Error("buddy 不该被判为可领(它没有 claim)")
|
||||
}
|
||||
}
|
||||
|
||||
// 自动签到的**调度配置**必须覆盖两项需求:领取 + 模型获取。
|
||||
//
|
||||
// auto_claim 走调度器(不是 HTTP export),故断言调度声明。
|
||||
func TestAutoClaimScheduledWithModelRefresh(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []struct{ name, needle string }{
|
||||
{"每日领取", `fn = "auto_claim"`},
|
||||
{"模型目录自动获取", `fn = "refresh_models"`},
|
||||
{"凭据自动续期", `fn = "refresh_creds"`},
|
||||
{"启动即补跑", `first_due_now = true`},
|
||||
} {
|
||||
if !strings.Contains(b, want.needle) {
|
||||
t.Errorf("调度表缺 %s(%s)", want.name, want.needle)
|
||||
}
|
||||
}
|
||||
// 模型刷新间隔照参考 MODEL_REFRESH_INTERVAL_MS(2 小时)
|
||||
if !strings.Contains(b, `fn = "refresh_models", every = 7200`) {
|
||||
t.Error("refresh_models 间隔应为 7200(参考 2h)")
|
||||
}
|
||||
// 按**行**判断(固定字符窗会溢到下一行,误报)
|
||||
lineOf := func(fn string) string {
|
||||
for _, ln := range strings.Split(b, "\n") {
|
||||
if strings.Contains(ln, `fn = "`+fn+`"`) {
|
||||
return ln
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// auto_claim **现在**也 first_due_now,且间隔远小于一天。
|
||||
//
|
||||
// ★ 这里改过一次,原因值得记下:原判据写的是「auto_claim 是每日任务,
|
||||
// 不该 first_due_now(重启就重领是错的)」—— 那个假设**本身是缺陷的一部分**。
|
||||
// 活动刷新时刻**各平台不同**(Qoder 是每日 10:00 UTC+8),一天只跑一次
|
||||
// 必然有平台整天漏领(参考实测:每天静默漏 100 积分)。修法有两半:
|
||||
// ① 频率提高 + first_due_now(任何时刻重启都能补上);
|
||||
// ② 「今天已覆盖」靠 covers_today 记账跳过(**这才是防重领的正确机制**,
|
||||
// 而不是靠"一天只跑一次"来碰巧避开)。
|
||||
// 只做 ② 不做 ① 仍会漏领;只做 ① 不做 ② 会重复打上游。
|
||||
if ln := lineOf("auto_claim"); !strings.Contains(ln, "first_due_now = true") {
|
||||
t.Errorf("auto_claim 应 first_due_now(否则重启后当天漏领):%s", ln)
|
||||
}
|
||||
// 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈)
|
||||
for _, fn := range []string{"refresh_models", "refresh_creds"} {
|
||||
ln := lineOf(fn)
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(ln, "first_due_now = true") {
|
||||
t.Errorf("%s 应声明 first_due_now(参考点名的重启自愈缺陷):%s", fn, ln)
|
||||
}
|
||||
}
|
||||
// auto_exclude 必须挡住 zcode(领取有代价:headful Chromium)
|
||||
if !strings.Contains(b, "auto_exclude = true") {
|
||||
t.Error("zcode 应标记 auto_exclude(照参考 isAutoCheckinExcluded)")
|
||||
}
|
||||
if i := strings.Index(b, `if cap.auto_exclude then`); i < 0 {
|
||||
t.Error("auto_claim 必须尊重 auto_exclude(否则会拉起浏览器)")
|
||||
}
|
||||
}
|
||||
|
||||
// creditsCapability 读插件的能力表条目。
|
||||
func creditsCapability(t *testing.T, ps interface {
|
||||
Call(string, string, map[string]interface{}) (interface{}, error)
|
||||
}, plat string) map[string]interface{} {
|
||||
t.Helper()
|
||||
m, err := ps.Call("channels", "credits_capability", map[string]interface{}{"platform": plat})
|
||||
if err != nil {
|
||||
t.Fatalf("credits_capability(%s): %v", plat, err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil {
|
||||
return nil
|
||||
}
|
||||
if cap, ok := j["capability"].(map[string]interface{}); ok {
|
||||
return cap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
122
internal/core/channels_balance_tier_test.go
Normal file
122
internal/core/channels_balance_tier_test.go
Normal file
@ -0,0 +1,122 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)。
|
||||
//
|
||||
// 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
|
||||
// 而**服务端扣哪个包不由我们决定** —— 插件能决定的是**用哪个号**:
|
||||
// 优先用「还有 15 天内到期积分」的号(那部分不用就作废),只剩永久积分的号排最后;
|
||||
// **锁定永久积分**时只剩永久积分的号等同于不可用。
|
||||
//
|
||||
// 判据用假余额上游,逐条钉住分档规则(含三个易错点)。
|
||||
func TestBalanceTierSelection(t *testing.T) {
|
||||
// 假 buddy 余额端点:返回三个包,覆盖三种情形
|
||||
// pkg A: 有效 + 10 天内到期 + 剩余 100 → 临时桶
|
||||
// pkg B: 有效 + 到期时间未知 + 剩余 50 → 永久桶(保守)
|
||||
// pkg C: **已失效** + 剩余 9999 → 必须跳过(否则虚增可用额度)
|
||||
future := nowMs() + 300*24*3600*1000 // 300 天后
|
||||
soon := nowMs() + 10*24*3600*1000 // 10 天后
|
||||
body := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"Response": map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Accounts": []map[string]interface{}{
|
||||
{"Status": 1, "CycleCapacityRemain": 100, "DeductionEndTime": soon},
|
||||
{"Status": 1, "CycleCapacityRemain": 50, "DeductionEndTime": future},
|
||||
{"Status": 2, "CycleCapacityRemain": 9999, "DeductionEndTime": future},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(raw)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
// 把一个 buddy 账号加进池,并把余额端点指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "buddy",
|
||||
"balance": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "tieracct",
|
||||
"credential": `{"access_token":"TOK"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 失败: %#v", m)
|
||||
}
|
||||
|
||||
// ① 分档:临时=100(只有 10 天内那个),永久=50(未知/远期),失效包被跳过
|
||||
out, err := ps.Call("channels", "credits_tier_probe",
|
||||
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
|
||||
if err != nil {
|
||||
t.Fatalf("分档探针: %v", err)
|
||||
}
|
||||
tw, _ := out.(map[string]interface{})
|
||||
tj, _ := tw["json"].(map[string]interface{})
|
||||
if tj == nil {
|
||||
t.Fatalf("无 json: %#v", out)
|
||||
}
|
||||
if tj["error"] != nil {
|
||||
t.Fatalf("分档报错: %v", tj["error"])
|
||||
}
|
||||
if got, _ := tj["expiring"].(float64); got != 100 {
|
||||
t.Errorf("临时余额应为 100(仅 10 天内到期的包),实得 %v", tj["expiring"])
|
||||
}
|
||||
if got, _ := tj["permanent"].(float64); got != 50 {
|
||||
t.Errorf("永久余额应为 50(到期未知/远期),实得 %v(若为 10049 说明失效包没跳过)",
|
||||
tj["permanent"])
|
||||
}
|
||||
|
||||
// ② 档位判定:有临时积分 → "expiring"(最优先)
|
||||
if tj["tier"] != "expiring" {
|
||||
t.Errorf("有 15 天内到期积分时档位应为 expiring,实得 %v", tj["tier"])
|
||||
}
|
||||
|
||||
// ③ 锁定永久积分:该账号**仍可用**(因为有临时积分)
|
||||
if _, err := ps.Call("channels", "__set_lock_permanent_probe",
|
||||
map[string]interface{}{"lock": true}); err != nil {
|
||||
t.Fatalf("锁定探针: %v", err)
|
||||
}
|
||||
out2, _ := ps.Call("channels", "credits_tier_probe",
|
||||
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
|
||||
tj2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if tj2["usable"] != true {
|
||||
t.Errorf("锁定永久积分时,有临时积分的号仍应可用,实得 usable=%v", tj2["usable"])
|
||||
}
|
||||
// ④ 窗口可被显式覆盖:把窗口设成 0 ⇒ 没有"临时积分" ⇒ 只剩永久 ⇒ 锁定后不可用
|
||||
if _, err := ps.Call("channels", "__set_expiring_window_probe",
|
||||
map[string]interface{}{"days": 0}); err != nil {
|
||||
t.Fatalf("窗口探针: %v", err)
|
||||
}
|
||||
out3, _ := ps.Call("channels", "credits_tier_probe",
|
||||
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
|
||||
tj3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if got, _ := tj3["expiring"].(float64); got != 0 {
|
||||
t.Errorf("窗口=0 时临时桶应为 0,实得 %v", tj3["expiring"])
|
||||
}
|
||||
if tj3["usable"] != false {
|
||||
t.Errorf("窗口=0 且锁定永久积分时该号应不可用,实得 usable=%v", tj3["usable"])
|
||||
}
|
||||
}
|
||||
|
||||
// nowMs 返回当前毫秒时间戳(与插件里 DeductionEndTime 的口径一致)。
|
||||
func nowMs() int64 { return time.Now().UnixMilli() }
|
||||
220
internal/core/channels_callback_loopback_test.go
Normal file
220
internal/core/channels_callback_loopback_test.go
Normal file
@ -0,0 +1,220 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 三个 callback 平台的回调路径**各是注册值**,且校验规则不同:
|
||||
//
|
||||
// Trae /authorize 不带 state(token 直接放 query 里)
|
||||
// 有道 /auth/callback 必须带 state
|
||||
// Gemini /oauth-callback 必须带 state
|
||||
//
|
||||
// 路径写错 → 上游 redirect_uri_mismatch;校验写错 → 要么接不住回调、
|
||||
// 要么被伪造回调塞入凭据。本测试打真上游形态(本地 loopback 接收器)。
|
||||
func TestCallbackPlatformsLoopback(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := func(t *testing.T, plat string) (m map[string]interface{}, nonce, sid string) {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s start_login: %v", plat, err)
|
||||
}
|
||||
m = out.JSON.(map[string]interface{})
|
||||
s := m["session"].(map[string]interface{})
|
||||
return m, s["nonce"].(string), s["id"].(string)
|
||||
}
|
||||
|
||||
poll := func(t *testing.T, sid string) map[string]interface{} {
|
||||
t.Helper()
|
||||
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return o.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
t.Run("行1 Trae 回调路径是 /authorize 且无 state 也能接住", func(t *testing.T) {
|
||||
_, _, sid := start(t, "trae")
|
||||
// 交换端点指到假上游
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"Token":"ACCESS-NEW"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "trae", "callback": map[string]interface{}{"exchange_url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 重新起会话,拿到带 loopback 的 redirect_uri
|
||||
m, nonce, sid := start(t, "trae")
|
||||
ru, _ := m["redirect_uri"].(string)
|
||||
if !strings.HasSuffix(ru, "/authorize") {
|
||||
t.Fatalf("trae 回调路径必须是 /authorize,实得 %q", ru)
|
||||
}
|
||||
// Trae 的回调**不带 state**,而是把 token 放 query 里
|
||||
q := url.Values{}
|
||||
q.Set("refreshToken", "RT-1")
|
||||
q.Set("userInfo", `{"UserID":"u1","ScreenName":"张三"}`)
|
||||
resp, err := http.Get(ru + "?" + q.Encode())
|
||||
if err != nil {
|
||||
t.Fatalf("打 loopback 接收器失败: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("Trae 回调(无 state)应被接住,实得 %d", resp.StatusCode)
|
||||
}
|
||||
st := waitState(t, poll, sid, "completed")
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("应完成登录,实得 %v", st)
|
||||
}
|
||||
raw, ok, _ := ps.GetSecret("channels", "channels:trae")
|
||||
if !ok {
|
||||
t.Fatal("凭据未落库")
|
||||
}
|
||||
var cred map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cred["access_token"] != "ACCESS-NEW" {
|
||||
t.Fatalf("凭据应是交换后的新 access,实得 %v", cred["access_token"])
|
||||
}
|
||||
if cred["uid"] != "u1" {
|
||||
t.Fatalf("uid 应从 userInfo.UserID 取出(签到设备身份的种子), 实得 %v", cred["uid"])
|
||||
}
|
||||
_ = nonce
|
||||
})
|
||||
|
||||
t.Run("行2 Gemini 路径是 /oauth-callback 且错 state 被拒", func(t *testing.T) {
|
||||
m, nonce, sid := start(t, "gemini")
|
||||
ru, _ := m["redirect_uri"].(string)
|
||||
if !strings.HasSuffix(ru, "/oauth-callback") {
|
||||
t.Fatalf("gemini 回调路径必须是 /oauth-callback(参考 GEMINI_CALLBACK_PATH),实得 %q", ru)
|
||||
}
|
||||
if !strings.HasPrefix(ru, "http://localhost:") {
|
||||
t.Fatalf("gemini 回调主机必须是 localhost(参考注释明确),实得 %q", ru)
|
||||
}
|
||||
// 错 state 必须被接收器拒(400),且不推进会话
|
||||
bad, err := http.Get(ru + "?code=C-1&state=WRONG")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad.Body.Close()
|
||||
if bad.StatusCode != 400 {
|
||||
t.Fatalf("state 不匹配应 400(防伪造回调),实得 %d", bad.StatusCode)
|
||||
}
|
||||
if st := poll(t, sid); st["state"] != "pending" {
|
||||
t.Fatalf("错 state 不应推进会话,实得 %v", st)
|
||||
}
|
||||
// 正确 state → 完成(交换端点指到假上游)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
// 必须带 client_secret:只发 client_id 会 invalid_request,
|
||||
// 症状正是「浏览器显示授权成功但账号不出现」
|
||||
if r.PostFormValue("client_secret") == "" {
|
||||
t.Error("token 交换必须带 client_secret")
|
||||
}
|
||||
if r.PostFormValue("grant_type") != "authorization_code" {
|
||||
t.Errorf("grant_type = %q", r.PostFormValue("grant_type"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token":"GA-1","refresh_token":"GR-1","expires_in":3600}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "gemini",
|
||||
"callback": map[string]interface{}{"token_url": ts.URL, "mode": "code"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good, err := http.Get(ru + "?code=C-1&state=" + nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
good.Body.Close()
|
||||
st := waitState(t, poll, sid, "completed")
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("应完成登录,实得 %v", st)
|
||||
}
|
||||
raw, ok, _ := ps.GetSecret("channels", "channels:gemini")
|
||||
if !ok {
|
||||
t.Fatal("凭据未落库")
|
||||
}
|
||||
var cred map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cred["access_token"] != "GA-1" {
|
||||
t.Fatalf("access_token = %v", cred["access_token"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// waitState 轮询到目标状态或超时(loopback 写文件是异步的,需要一拍)。
|
||||
func waitState(t *testing.T, poll func(*testing.T, string) map[string]interface{},
|
||||
sid, want string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var st map[string]interface{}
|
||||
for i := 0; i < 30; i++ {
|
||||
st = poll(t, sid)
|
||||
if st["state"] != "pending" {
|
||||
return st
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// 占位符未替换检查:授权 URL 里残留 {xxx} 会让上游收到字面量占位符
|
||||
// (而且往往不报错,只是行为诡异)。三个 callback 平台逐个验。
|
||||
func TestAuthorizeURLsHaveNoUnfilledPlaceholders(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", plat, err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
for _, key := range []string{"url", "redirect_uri"} {
|
||||
v, _ := m[key].(string)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(v, "{") || strings.Contains(v, "nil") {
|
||||
t.Errorf("%s 的 %s 仍有未替换占位符: %s", plat, key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
internal/core/channels_capability_parse_test.go
Normal file
55
internal/core/channels_capability_parse_test.go
Normal file
@ -0,0 +1,55 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 用**真实的 models.dev 响应**验证解析口径(5.6MB / 229 键 / opencode 122 模型)。
|
||||
//
|
||||
// 为什么必须用真数据:字段路径(modalities.input / cost.input / limit.context)
|
||||
// 是我照参考注释写的,只有真数据能证明写对了。合成样本会把这个验证变成自说自话。
|
||||
//
|
||||
// 样本不在仓库里(5.6MB),缺失时跳过 —— 但 CI 里能拉到就一定会跑。
|
||||
func TestCapabilityParseAgainstRealModelsDev(t *testing.T) {
|
||||
b, err := os.ReadFile("/tmp/modelsdev.json")
|
||||
if err != nil {
|
||||
t.Skip("无 models.dev 样本(/tmp/modelsdev.json),跳过")
|
||||
}
|
||||
_, ps := poolCore(t)
|
||||
out, err := ps.Call("channels", "__parse_capability_probe", map[string]interface{}{
|
||||
"body": string(b), "key": "opencode",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j["error"] != nil {
|
||||
t.Fatalf("解析失败: %v", j["error"])
|
||||
}
|
||||
total, _ := j["total"].(float64)
|
||||
free, _ := j["free"].(float64)
|
||||
image, _ := j["image"].(float64)
|
||||
ctx, _ := j["with_ctx"].(float64)
|
||||
t.Logf("opencode: total=%.0f free=%.0f image=%.0f with_ctx=%.0f", total, free, image, ctx)
|
||||
|
||||
// ① 规模:实测 122 个模型(允许上游增减,给个合理区间)
|
||||
if total < 50 {
|
||||
t.Errorf("解析出的模型数太少(%.0f)—— 字段路径可能没对上真实结构", total)
|
||||
}
|
||||
// ② 免费判定必须有命中的(实测有若干 cost 全 0 的模型)
|
||||
if free == 0 {
|
||||
t.Error("一个免费模型都没识别出来 —— cost 判定路径可能错了")
|
||||
}
|
||||
if free >= total {
|
||||
t.Error("全部被标免费 —— cost 判定过于宽松")
|
||||
}
|
||||
// ③ 多模态也要有命中的(gpt-5.4 之类含 image)
|
||||
if image == 0 {
|
||||
t.Error("没识别出任何多模态模型 —— modalities 路径可能错了")
|
||||
}
|
||||
// ④ 上下文窗口要能取到(limit.context)
|
||||
if ctx == 0 {
|
||||
t.Error("没取到任何上下文窗口 —— limit 路径可能错了")
|
||||
}
|
||||
}
|
||||
86
internal/core/channels_capability_test.go
Normal file
86
internal/core/channels_capability_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 模型能力元数据(照参考 opencode-capability)。
|
||||
//
|
||||
// 关键纪律:`/zen/v1/models` 只返回 4 个字段、**不含能力信息**;能力在 models.dev
|
||||
// (实测 5.05 MB / 下载 1.4s)。参考的真机事故:最初在模型列表渲染路径上直接
|
||||
// 等这个下载,模型选择器要等 1.4s+ 才出列表 —— 用户报障「点开是一片空白」。
|
||||
//
|
||||
// ⇒ 三条硬约定:渲染路径只读同步缓存;磁盘缓存冷启动命中;拉取在后台。
|
||||
// 我们额外:本插件是服务端进程,但 5MB 仍不能放请求路径上 ⇒ 挂定时任务。
|
||||
//
|
||||
// 本判据钉住"非阻塞"这一条:**缓存未就绪时 model_list 必须立刻返回**,
|
||||
// 而不是去等网络。
|
||||
func TestCapabilityReadNeverBlocks(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
// 清掉能力缓存(模拟冷启动)
|
||||
if _, err := ps.Call("channels", "__clear_capabilities_probe", nil); err != nil {
|
||||
t.Fatalf("清缓存: %v", err)
|
||||
}
|
||||
// 冷启动下取模型列表:必须成功返回(退回兜底 isFree),且不得等待网络。
|
||||
// 判据:能返回就说明没阻塞(真去下 5MB 会超时/极慢)。
|
||||
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
|
||||
if err != nil {
|
||||
t.Fatalf("model_list: %v", err)
|
||||
}
|
||||
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j == nil || j["ok"] != true {
|
||||
t.Fatalf("冷启动下 model_list 必须成功(退回兜底),实得 %#v", m)
|
||||
}
|
||||
models := asArrInterface(j["models"])
|
||||
if len(models) == 0 {
|
||||
t.Fatal("冷启动下仍应列出兜底模型")
|
||||
}
|
||||
// 冷启动时 isFree 应退回声明的 models_free(不能凭空说全部免费)
|
||||
sawFree := false
|
||||
for _, it := range models {
|
||||
if mm, ok := it.(map[string]interface{}); ok && mm["isFree"] == true {
|
||||
sawFree = true
|
||||
}
|
||||
}
|
||||
if !sawFree {
|
||||
t.Error("冷启动时 opencode 应有 free 模型(退回声明的 models_free)")
|
||||
}
|
||||
}
|
||||
|
||||
// 能力元数据一旦就绪,isFree 必须改由它决定(cost 权威,而非快照)。
|
||||
func TestCapabilityOverridesFreeFlag(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
// 注入一份假能力:把某个原本标 free 的模型改成收费、把非 free 的改成免费
|
||||
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
|
||||
"platform": "opencode",
|
||||
"model_id": "big-pickle",
|
||||
"is_free": false, // 原本声明 free,能力表说收费 ⇒ 必须以能力表为准
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
|
||||
"platform": "opencode",
|
||||
"model_id": "glm-5.2",
|
||||
"is_free": true, // 原本不在 free 列表,能力表说免费
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
m, _ := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
|
||||
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
|
||||
got := map[string]bool{}
|
||||
for _, it := range asArrInterface(j["models"]) {
|
||||
if mm, ok := it.(map[string]interface{}); ok {
|
||||
if id, ok := mm["id"].(string); ok {
|
||||
if f, ok := mm["isFree"].(bool); ok {
|
||||
got[id] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if got["big-pickle"] {
|
||||
t.Error("能力表说收费时 isFree 必须为 false(权威来源是 cost,不是快照)")
|
||||
}
|
||||
if !got["glm-5.2"] {
|
||||
t.Error("能力表说免费时 isFree 必须为 true")
|
||||
}
|
||||
}
|
||||
91
internal/core/channels_cline_adapter_test.go
Normal file
91
internal/core/channels_cline_adapter_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cline 的 429/402 话术与冷却时长(照参考 cline-rate-limit.ts)。
|
||||
//
|
||||
// ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",用户看到
|
||||
// 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就从当下重新计一小时
|
||||
// ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
|
||||
// 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游。
|
||||
//
|
||||
// 抓包真值(参考实测,no-retry: true —— 没有任何 retry-after 头):
|
||||
//
|
||||
// HTTP 429
|
||||
// {"error":{"code":"INFERENCE_CAP_ERROR",
|
||||
// "message":"Error 429: Daily free limit reached on model
|
||||
// deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
|
||||
func TestClineAdapterParsesProseWait(t *testing.T) {
|
||||
c, _ := poolCore(t)
|
||||
vm := c.VM()
|
||||
|
||||
// ① 当日免费额度用尽:必须被归一成**内核已认的中立短语**
|
||||
//(否则内核按 30 秒短冷却处理 ⇒ 每 30 秒猛敲上游)
|
||||
body := `{"error":{"code":"INFERENCE_CAP_ERROR","message":` +
|
||||
`"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 19h 39m"}}`
|
||||
out, ok, err := vm.TransformError("channels-cline", 429, body)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("transform_error: err=%v ok=%v", err, ok)
|
||||
}
|
||||
if !strings.Contains(out, "额度已用完") {
|
||||
t.Errorf("必须归一成内核已认的「额度已用完」(否则不会被当成额度耗尽处理),实得 %q", out)
|
||||
}
|
||||
// 真实等待时长必须出现在话术里(19h39m ≈ 19 小时)
|
||||
if !strings.Contains(out, "19") {
|
||||
t.Errorf("必须把真实等待时长写进话术(用户才知道要等多久),实得 %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "重试无用") {
|
||||
t.Errorf("当日额度用尽要明确告知重试无用(否则用户以为等一会就好),实得 %q", out)
|
||||
}
|
||||
// ⚠️ 绝不能出现参考踩过的「1 小时」误导
|
||||
if strings.Contains(out, "1 小时") && !strings.Contains(out, "19") {
|
||||
t.Errorf("不得退回快照式的「1 小时」误导,实得 %q", out)
|
||||
}
|
||||
|
||||
// ② 时长 token 必须**累加**(`1h 30m` = 90 分钟),不能只取第一个 ——
|
||||
// 通过真实的 transform_error 路径验证(不自己重算解析器)。
|
||||
acc, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Daily free limit reached. Try again in 1h 30m"}}`)
|
||||
if !strings.Contains(acc, "1 小时 30 分") {
|
||||
t.Errorf("`1h 30m` 必须累加成人话时长(1 小时 30 分),实得 %q", acc)
|
||||
}
|
||||
// 秒级也要能解析(否则秒级限流会退化成通用文案)
|
||||
secs, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Rate limited. Try again in 45s"}}`)
|
||||
if !strings.Contains(secs, "分") && !strings.Contains(secs, "秒") {
|
||||
t.Errorf("秒级时长应被解析并说出,实得 %q", secs)
|
||||
}
|
||||
|
||||
// ③ 402:等待不会恢复,必须说清楚要充值(不能只说"限流")
|
||||
out402, _, _ := vm.TransformError("channels-cline", 402,
|
||||
`{"error":{"message":"Insufficient credits"}}`)
|
||||
if !strings.Contains(out402, "充值") {
|
||||
t.Errorf("402 应明确提示需充值(等待无用),实得 %q", out402)
|
||||
}
|
||||
|
||||
// ④ 普通 429 带时长:如实说,但不谎报成额度耗尽
|
||||
out429, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Rate limited. Try again in 30s"}}`)
|
||||
if strings.Contains(out429, "额度已用完") {
|
||||
t.Errorf("普通限流不得谎报成额度耗尽(那会让内核长时间冷却一个健康源),实得 %q", out429)
|
||||
}
|
||||
if !strings.Contains(out429, "限流") {
|
||||
t.Errorf("普通 429 应说成限流,实得 %q", out429)
|
||||
}
|
||||
|
||||
// ⑤ 多种外壳都要认(`{error:"…"}` 字符串 / `{msg}` / 非 JSON 纯文本)
|
||||
//(只认一种会在别的形态下静默丢掉整条信息)
|
||||
for _, shell := range []string{
|
||||
`{"error":"Daily free limit reached. Try again in 1h"}`,
|
||||
`{"msg":"Daily free limit reached. Try again in 1h"}`,
|
||||
`Daily free limit reached. Try again in 1h`,
|
||||
} {
|
||||
o, _, _ := vm.TransformError("channels-cline", 429, shell)
|
||||
if !strings.Contains(o, "额度已用完") {
|
||||
t.Errorf("外壳 %q 未被识别为额度耗尽,实得 %q", shell, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
171
internal/core/channels_codearts_adapter_test.go
Normal file
171
internal/core/channels_codearts_adapter_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CodeArts 专用适配器是通过**插件自装**(__llmsproxy_adapters.install)
|
||||
// 引入的,不是内置适配器 —— 因为 AK/SK 签名属厂商知识,内置里出现 codearts
|
||||
// 就破了"内核零厂商名"纪律。
|
||||
//
|
||||
// 这组判据要证明三件事:
|
||||
// 1. 插件加载时确实把适配器装上了
|
||||
// 2. 装上之后,源能引用它(不是"装了但引用不到")
|
||||
// 3. 它产出的签名是**真的能验**(而不是拼出个像签名的字符串)
|
||||
func TestCodeArtsAdapterInstalledByPlugin(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 适配器必须在插件加载后就位
|
||||
adapters := c.ListAdapters()
|
||||
found := false
|
||||
for _, a := range adapters {
|
||||
if a.Name == "codearts" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("插件未装 codearts 适配器;已装: %#v", adapters)
|
||||
}
|
||||
|
||||
// ② 归属必须是 channels 插件(证明是它装的,不是内置)
|
||||
if owner := ps.AdapterOwnerOf("codearts"); owner != "channels" {
|
||||
t.Errorf("适配器归属应为 channels,实得 %q", owner)
|
||||
}
|
||||
|
||||
// ③ 签名必须真的可验:用 Go 标准库按 SDK-HMAC-SHA256 独立复算
|
||||
got, err := ps.Call("channels", "__codearts_adapter_sign_probe", map[string]interface{}{
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/api/v2/chat/completions",
|
||||
"method": "POST",
|
||||
"body": `{"model":"CodeArts-Pangu"}`,
|
||||
"api_key": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST","security_token":"STTEST"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("签名探针: %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("探针无 json: %#v", got)
|
||||
}
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("探针报错: %v", m["error"])
|
||||
}
|
||||
auth, _ := m["authorization"].(string)
|
||||
if !strings.HasPrefix(auth, "SDK-HMAC-SHA256 Access=AKTEST,SignedHeaders=") {
|
||||
t.Fatalf("Authorization 形态不符(参考要求 Access= 不是 Credential=): %s", auth)
|
||||
}
|
||||
if !strings.Contains(auth, "Signature=") {
|
||||
t.Fatalf("Authorization 缺 Signature: %s", auth)
|
||||
}
|
||||
// 签名后追加的头必须在,且不参与 canonical
|
||||
for _, k := range []string{"Agent-Type", "X-Language"} {
|
||||
if m["hdr_"+k] == nil || m["hdr_"+k] == "" {
|
||||
t.Errorf("缺签名后追加的头 %s", k)
|
||||
}
|
||||
}
|
||||
if m["hdr_Agent-Type"] != "PromptCenter" {
|
||||
t.Errorf("Agent-Type = %v,应为 PromptCenter", m["hdr_Agent-Type"])
|
||||
}
|
||||
// canonical 不得包含签名后追加的头(参考注释点名:它们是签名后加的)
|
||||
canon, _ := m["canonical"].(string)
|
||||
if strings.Contains(canon, "agent-type") || strings.Contains(canon, "x-language") {
|
||||
t.Errorf("canonical 不应含签名后追加的头:\n%s", canon)
|
||||
}
|
||||
// Authorization 不得进 canonical
|
||||
if strings.Contains(canon, "authorization") {
|
||||
t.Errorf("canonical 不应含 authorization:\n%s", canon)
|
||||
}
|
||||
// x-sdk-content-sha256 说明 payload hash 参与了签名
|
||||
if !strings.Contains(canon, "x-sdk-content-sha256") {
|
||||
t.Errorf("canonical 应含 x-sdk-content-sha256:\n%s", canon)
|
||||
}
|
||||
// URI 必须以 / 结尾(参考点名的易错点)
|
||||
if !strings.Contains(canon, "/api/v2/chat/completions/\n") {
|
||||
t.Errorf("canonical 的 URI 未补尾斜杠(参考点名的易错点):\n%s", canon)
|
||||
}
|
||||
// ④ **独立验签**:用 Go 标准库按 SDK-HMAC-SHA256 从 canonical 复算签名,
|
||||
// 必须与适配器给出的 Signature 逐字节相同。
|
||||
//
|
||||
// 为什么必须有这一步:前面几条只验证"形态正确"(Access=、SignedHeaders、
|
||||
// 尾斜杠)。签名算法本身写错(HMAC 输入少一段、canonical 分隔符用错)时
|
||||
// 形态依然全对,但上游会 401。复算才是真正的判据。
|
||||
sts, _ := m["string_to_sign"].(string)
|
||||
if sts == "" {
|
||||
t.Fatal("探针未带出 string_to_sign(判据必须比对真实函数算出的中间值)")
|
||||
}
|
||||
// string_to_sign = "SDK-HMAC-SHA256\n" + date + "\n" + sha256(canonical)
|
||||
parts := strings.SplitN(sts, "\n", 3)
|
||||
if len(parts) != 3 || parts[0] != "SDK-HMAC-SHA256" {
|
||||
t.Fatalf("string_to_sign 头段不符: %q", sts)
|
||||
}
|
||||
canonSum := sha256.Sum256([]byte(canon))
|
||||
if parts[2] != hex.EncodeToString(canonSum[:]) {
|
||||
t.Errorf("string_to_sign 的 canonical 摘要不符\n算得: %s\n实得: %s",
|
||||
hex.EncodeToString(canonSum[:]), parts[2])
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte("SKTEST"))
|
||||
mac.Write([]byte(sts))
|
||||
wantSig := hex.EncodeToString(mac.Sum(nil))
|
||||
gotSig := auth[strings.Index(auth, "Signature=")+len("Signature="):]
|
||||
if gotSig != wantSig {
|
||||
t.Errorf("签名独立复算不符(算法写错?)\n复算: %s\n实得: %s", wantSig, gotSig)
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(m)
|
||||
t.Logf("签名探针输出: %s", string(b))
|
||||
}
|
||||
|
||||
// 源注册必须真的引用插件装的 codearts 适配器 —— "装了但源还指向 openai"
|
||||
// 是这类改动最可能的漏配,且不会报错(只是签名不生效、上游 401)。
|
||||
func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
|
||||
// ⚠️ 必须用 poolCore:它给 channels 授予了 add_source/list_sources 等
|
||||
// 内核权限。用 kernelTestCore 裸加载插件时 add_source 会被拒
|
||||
//("kernel action add_source is not granted to plugin channels"),
|
||||
// 而这个拒绝**不报错、只是不出源** —— 我就在这里绕了一圈才看出根因。
|
||||
c, ps := poolCore(t)
|
||||
// 建一个 codearts 账号 → 触发源注册
|
||||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 报错: %#v", m)
|
||||
}
|
||||
aid, _ := j["account_id"].(string)
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
if !ok {
|
||||
names := []string{}
|
||||
for _, x := range srcs {
|
||||
if n, _ := x["name"].(string); n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
t.Fatalf("codearts 账号 %q 应已同步源;现有源: %v", aid, names)
|
||||
}
|
||||
if ad, _ := sm["adapter"].(string); ad != "codearts" {
|
||||
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)
|
||||
}
|
||||
bu, _ := sm["base_url"].(string)
|
||||
if !strings.Contains(bu, "snap-access") {
|
||||
t.Errorf("base_url 不符: %q", bu)
|
||||
}
|
||||
}
|
||||
186
internal/core/channels_codearts_oauth_flow_test.go
Normal file
186
internal/core/channels_codearts_oauth_flow_test.go
Normal file
@ -0,0 +1,186 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// CodeArts 端到端:起真实回调服务器 → 模上游回 code → 走真实 STS 交换
|
||||
// (带 DPoP)→ 凭据落盘。判据检查**交换请求本身的形态**(DPoP 头、
|
||||
// grant_type、code_verifier、redirect_uri 端口),因为只断言"存了个 token"
|
||||
// 无法防止把 OAuth 悄悄换回手填。
|
||||
func TestCodeArtsOAuthEndToEnd(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 假 STS 上游:校验请求形态,返回 credentials + refresh_token
|
||||
var gotForm url.Values
|
||||
var gotDpop string
|
||||
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotDpop = r.Header.Get("DPoP")
|
||||
_ = r.ParseForm()
|
||||
gotForm = r.PostForm
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"credentials": map[string]interface{}{
|
||||
"access_key_id": "AKTEST",
|
||||
"secret_access_key": "SKTEST",
|
||||
"security_token": "STTEST",
|
||||
"expiration": "2026-12-31T00:00:00Z",
|
||||
},
|
||||
"refresh_token": "RT-TEST",
|
||||
})
|
||||
}))
|
||||
defer sts.Close()
|
||||
|
||||
// 把 STS 端点指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "codearts",
|
||||
"codearts_sts": map[string]interface{}{"url": sts.URL},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
|
||||
// 1) start_login → 拿回调地址与登录 URL
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("start_login 报错: %v", m["error"])
|
||||
}
|
||||
loginURL, _ := m["url"].(string)
|
||||
redirectURI, _ := m["redirect_uri"].(string)
|
||||
sess := m["session"].(map[string]interface{})
|
||||
sid, _ := sess["id"].(string)
|
||||
t.Logf("login_url = %s", loginURL)
|
||||
t.Logf("redirect_uri = %s", redirectURI)
|
||||
|
||||
// 2) 登录 URL 必须逐项符合参考 buildOAuthLoginUrl
|
||||
if !strings.HasPrefix(loginURL, "https://codearts.huaweicloud.com/portal/authorize?") {
|
||||
t.Fatalf("authorize base 不对: %s", loginURL)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"client_id=codearts-agent",
|
||||
"uri_scheme=codearts-agent",
|
||||
"code_challenge_method=SHA-256", // 不是 S256
|
||||
"plugin-name=snap_AIIDE",
|
||||
"plugin-version=5.2.0",
|
||||
"theme=2",
|
||||
"locale=zh-cn",
|
||||
} {
|
||||
if !strings.Contains(loginURL, want) {
|
||||
t.Errorf("authorize URL 缺 %q\n实际: %s", want, loginURL)
|
||||
}
|
||||
}
|
||||
// 反向:**不得**含 auth_callback_url(参考注释:会被 portal 判异常并回退旧流程)
|
||||
if strings.Contains(loginURL, "auth_callback_url") {
|
||||
t.Errorf("authorize URL 不应含 auth_callback_url(会导致 portal 回退旧流程): %s", loginURL)
|
||||
}
|
||||
// code_challenge 必须存在且非空
|
||||
if !strings.Contains(loginURL, "code_challenge=") ||
|
||||
strings.Contains(loginURL, "code_challenge=&") {
|
||||
t.Errorf("缺 code_challenge: %s", loginURL)
|
||||
}
|
||||
|
||||
// 3) 模拟上游重定向到回调(带 code)
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("code", "AUTHCODE-TEST")
|
||||
u.RawQuery = q.Encode()
|
||||
resp, err := http.Get(u.String())
|
||||
if err != nil {
|
||||
t.Fatalf("打回调: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// 4) poll_login → 触发真实 STS 交换
|
||||
pout, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("poll_login: %v", err)
|
||||
}
|
||||
pm := pout.JSON.(map[string]interface{})
|
||||
if pm["state"] != "completed" && pm["state"] != "success" && pm["state"] != "done" {
|
||||
t.Fatalf("poll_login 未成功: %#v", pm)
|
||||
}
|
||||
|
||||
// 5) 交换请求形态(判据的核心)
|
||||
if gotDpop == "" {
|
||||
t.Fatal("STS 请求缺 DPoP 头 —— 不是新式 OAuth")
|
||||
}
|
||||
if strings.Count(gotDpop, ".") != 2 {
|
||||
t.Errorf("DPoP 应是三段 JWS,实得 %q", gotDpop)
|
||||
}
|
||||
if gotForm.Get("grant_type") != "authorization_code" {
|
||||
t.Errorf("grant_type = %q,应为 authorization_code", gotForm.Get("grant_type"))
|
||||
}
|
||||
if gotForm.Get("client_id") != "codearts-agent" {
|
||||
t.Errorf("client_id = %q", gotForm.Get("client_id"))
|
||||
}
|
||||
if gotForm.Get("code_verifier") == "" {
|
||||
t.Error("缺 code_verifier —— PKCE 没生效")
|
||||
}
|
||||
if gotForm.Get("code") != "AUTHCODE-TEST" {
|
||||
t.Errorf("code = %q", gotForm.Get("code"))
|
||||
}
|
||||
ruWant := "http://127.0.0.1" + u.Port() + "/oauth/callback"
|
||||
if !strings.HasPrefix(redirectURI, ruWant[:len("http://127.0.0.1")]) {
|
||||
t.Errorf("redirect_uri 主机不对: %s", redirectURI)
|
||||
}
|
||||
if gotForm.Get("redirect_uri") != redirectURI {
|
||||
t.Errorf("redirect_uri = %q,应为回调地址 %q", gotForm.Get("redirect_uri"), redirectURI)
|
||||
}
|
||||
|
||||
// 6) 凭据必须含续期材料(refresh_token / code_verifier / DPoP 私钥 JWK)
|
||||
// 凭据必须含**续期材料** —— 这是 OAuth 流程存在的意义。
|
||||
// 只断言"登录成功"无法防止凭据被截断成裸 AK/SK(那样 refresh 就废了)。
|
||||
raw, err := ps.Call("channels", "__codearts_cred_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("凭据探针: %v", err)
|
||||
}
|
||||
w, _ := raw.(map[string]interface{})
|
||||
cj, _ := w["json"].(map[string]interface{})
|
||||
if cj == nil {
|
||||
t.Fatalf("探针无 json: %#v", raw)
|
||||
}
|
||||
if cj["error"] != nil {
|
||||
t.Fatalf("凭据未落盘: %v (refs=%v)", cj["error"], cj["refs"])
|
||||
}
|
||||
t.Logf("凭据 ref=%v access_key_id=%v", cj["ref"], cj["access_key_id"])
|
||||
if cj["access_key_id"] != "AKTEST" {
|
||||
t.Errorf("access_key_id = %v,应为 AKTEST(STS 响应未正确解析)", cj["access_key_id"])
|
||||
}
|
||||
for _, k := range []string{"refresh_token", "code_verifier", "dpop_private_key_jwk"} {
|
||||
if cj[k] == nil || cj[k] == "" {
|
||||
t.Errorf("凭据缺 %s(无法静默续期)", k)
|
||||
}
|
||||
}
|
||||
if cj["refresh_token"] != "RT-TEST" {
|
||||
t.Errorf("refresh_token = %v,应为 RT-TEST", cj["refresh_token"])
|
||||
}
|
||||
_ = json.Marshal
|
||||
|
||||
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"session_id":"` + sid + `"}`, Role: "admin",
|
||||
})
|
||||
}
|
||||
112
internal/core/channels_codearts_oauth_test.go
Normal file
112
internal/core/channels_codearts_oauth_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CodeArts 的 DPoP 证明是标准 ES256 JWS。用 Go 标准库(crypto/ecdsa)当权威
|
||||
// 校验器验它 —— 只断言"能拼出字符串"是不够的:JWS 的 ES256 签名必须是
|
||||
// **raw r||s(各 32 字节)**,而 openssl 默认输出 DER;写错格式服务端会拒,
|
||||
// 而字符串看起来完全正常(这是最容易蒙混过去的一类缺陷)。
|
||||
func TestCodeArtsDpopJwsVerifiesAsES256(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ps.Call("channels", "__codearts_dpop_probe",
|
||||
map[string]interface{}{"htm": "POST", "htu": "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("无 json: %#v", got)
|
||||
}
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("探针报错: %v", m["error"])
|
||||
}
|
||||
jws, _ := m["jws"].(string)
|
||||
if jws == "" {
|
||||
t.Fatal("jws 为空")
|
||||
}
|
||||
parts := strings.Split(jws, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("JWS 应为三段,实得 %d 段", len(parts))
|
||||
}
|
||||
dec := func(s string) []byte {
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
t.Fatalf("base64url 解码失败: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
// ---- header ----
|
||||
var hdr map[string]interface{}
|
||||
if err := json.Unmarshal(dec(parts[0]), &hdr); err != nil {
|
||||
t.Fatalf("header 不是 JSON: %v", err)
|
||||
}
|
||||
if hdr["alg"] != "ES256" {
|
||||
t.Errorf("alg 应为 ES256,实得 %v", hdr["alg"])
|
||||
}
|
||||
if hdr["typ"] != "dpop+jwt" {
|
||||
t.Errorf("typ 应为 dpop+jwt,实得 %v", hdr["typ"])
|
||||
}
|
||||
jwk, _ := hdr["jwk"].(map[string]interface{})
|
||||
if jwk == nil {
|
||||
t.Fatal("header 必须内嵌 jwk(DPoP 要求公钥随证明走)")
|
||||
}
|
||||
// ---- 用 jwk 重建公钥 ----
|
||||
xb, err := base64.RawURLEncoding.DecodeString(jwk["x"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yb, err := base64.RawURLEncoding.DecodeString(jwk["y"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(xb) != 32 || len(yb) != 32 {
|
||||
t.Fatalf("P-256 的 x/y 应各 32 字节,实得 %d/%d", len(xb), len(yb))
|
||||
}
|
||||
pub := &ecdsa.PublicKey{Curve: elliptic.P256(),
|
||||
X: new(big.Int).SetBytes(xb), Y: new(big.Int).SetBytes(yb)}
|
||||
|
||||
// ---- payload ----
|
||||
var pl map[string]interface{}
|
||||
if err := json.Unmarshal(dec(parts[1]), &pl); err != nil {
|
||||
t.Fatalf("payload 不是 JSON: %v", err)
|
||||
}
|
||||
for _, k := range []string{"htm", "htu", "iat", "jti"} {
|
||||
if _, ok := pl[k]; !ok {
|
||||
t.Errorf("payload 缺 %s(照参考 signDpopJws)", k)
|
||||
}
|
||||
}
|
||||
if pl["htm"] != "POST" {
|
||||
t.Errorf("htm = %v", pl["htm"])
|
||||
}
|
||||
|
||||
// ---- 验签(关键:raw r||s,不是 DER)----
|
||||
sig := dec(parts[2])
|
||||
if len(sig) != 64 {
|
||||
t.Fatalf("ES256 签名必须是 64 字节 raw r||s,实得 %d 字节(若是 70-72 字节,说明漏了 DER→raw 转换)", len(sig))
|
||||
}
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if !ecdsa.Verify(pub, digest[:], r, s) {
|
||||
t.Fatal("ES256 验签失败 —— 签名内容或格式不对,服务端会拒绝这个 DPoP")
|
||||
}
|
||||
}
|
||||
65
internal/core/channels_codearts_port_test.go
Normal file
65
internal/core/channels_codearts_port_test.go
Normal file
@ -0,0 +1,65 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// CodeArts 的 portal 拒绝 <10000 的回调端口(参考 listenOnCallbackPort 的
|
||||
// MIN_CALLBACK_PORT = 10000 及 `assigned >= MIN_CALLBACK_PORT` 校验)。
|
||||
// 判据直接量**实际分配的端口**,不复用参考常量做断言来源。
|
||||
func TestCodeArtsLoopbackPortAtLeast10000(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
m, _ := out.JSON.(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("无 JSON: %#v", out)
|
||||
}
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("报错: %v", m["error"])
|
||||
}
|
||||
ru, _ := m["redirect_uri"].(string)
|
||||
if ru == "" {
|
||||
t.Fatalf("无 redirect_uri: %#v", m)
|
||||
}
|
||||
t.Logf("redirect_uri = %s", ru)
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(ru, "http://127.0.0.1:%d/", &port); err != nil {
|
||||
if _, err2 := fmt.Sscanf(ru, "http://localhost:%d/", &port); err2 != nil {
|
||||
t.Fatalf("解析端口失败: %v / %v (%s)", err, err2, ru)
|
||||
}
|
||||
}
|
||||
if port < 10000 {
|
||||
t.Fatalf("回调端口 %d < 10000 —— portal 会拒绝(参考 MIN_CALLBACK_PORT)", port)
|
||||
}
|
||||
// 回调路径也必须是注册值 /oauth/callback(参考 REDIRECT_PATH)
|
||||
if !hasSuffix(ru, "/oauth/callback") {
|
||||
t.Fatalf("回调路径应为 /oauth/callback,实得 %s", ru)
|
||||
}
|
||||
if s, _ := m["session"].(map[string]interface{}); s != nil {
|
||||
if id, _ := s["id"].(string); id != "" {
|
||||
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"session_id":"` + id + `"}`, Role: "admin",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasSuffix(s, suf string) bool {
|
||||
return len(s) >= len(suf) && s[len(s)-len(suf):] == suf
|
||||
}
|
||||
204
internal/core/channels_dead_model_test.go
Normal file
204
internal/core/channels_dead_model_test.go
Normal file
@ -0,0 +1,204 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 「已失效模型」判据必须严格 —— 误判的代价是把**可用模型藏 30 天**。
|
||||
//
|
||||
// 参考为这些判据写了很长的注释(都是实测误判教训),本判据把它们钉住:
|
||||
//
|
||||
// - `model_not_found` 在遥测/日志里常作**字段名**出现(后面还有内容),
|
||||
// 只在它"独占一句话成分"时才算阳性;
|
||||
// - rate limit / quota / unauthor / forbidden 一律**不是**下架;
|
||||
// - `expired` 必须带主语(token/session/key/…),否则会误杀正常下架文案
|
||||
// `model expired-v3 does not exist`;
|
||||
// - 只认 found/exist,**不认** available/supported(那是"换套餐就能恢复")。
|
||||
func TestDeadModelGoneJudgement(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
msg string
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
// ── 阳性:真的是下架 ──
|
||||
{"model not found", true, "经典文案"},
|
||||
{"unknown model: foo", true, "unknown model"},
|
||||
{"no such model", true, "no such model"},
|
||||
{"The model does not exist", true, "does not exist"},
|
||||
{"error: model_not_found", true, "独占一句话(行尾)"},
|
||||
{"model_not_found.", true, "后面是句末标点"},
|
||||
// ── 阴性:看着像但**不是**下架 ──
|
||||
{"error_class=model_not_found, retry allowed", false,
|
||||
"字段名后面还有内容(初版误判下架,实测教训)"},
|
||||
{"the model_not_found metric was not exported", false,
|
||||
"作为指标名出现(实测教训)"},
|
||||
{"rate limit exceeded for model foo", false, "限流换时段就能用"},
|
||||
{"quota exceeded", false, "额度问题"},
|
||||
{"insufficient balance", false, "余额问题"},
|
||||
{"401 unauthorized", false, "认证问题"},
|
||||
{"invalid api key", false, "认证问题"},
|
||||
{"token expired", false, "token 过期(带主语)"},
|
||||
// ⚠️ 这条最微妙:expired 无主语时**不**能触发 NOT_GONE,
|
||||
// 否则会误杀 "model expired-v3 does not exist" 这类真实下架文案。
|
||||
{"model expired-v3 does not exist", true,
|
||||
"expired 无主语 → 不算认证失败;does not exist 才是真判据"},
|
||||
// ⚠️ 两条更正(对照参考实现的实际行为,不是注释字面):
|
||||
// 1. `lease expired` 单独出现**不算**下架 —— 它是租约/认证问题。
|
||||
// 2. 即便带上 does not exist 也**仍不算**:参考的判定顺序是
|
||||
// NOT_GONE 先于 GONE(`isModelGoneError` 第 265 行先查排除表),
|
||||
// 一旦命中排除词就直接 false、不再看后面的不存在语义。
|
||||
// 我最初按注释字面以为"带主语才对、带上就想判下架",是读错了。
|
||||
{"the model lease expired", false, "租约/认证问题,不是下架"},
|
||||
{"the model lease expired and does not exist", false,
|
||||
"参考的顺序是 NOT_GONE 先行 ⇒ 命中排除词即 false"},
|
||||
// available/supported 不算(换套餐就能恢复)
|
||||
{"model not available in your region", false,
|
||||
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
|
||||
{"", false, "空串"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
out, err := ps.Call("channels", "__is_model_gone_probe",
|
||||
map[string]interface{}{"message": tc.msg})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
w, _ := out.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
got, _ := j["gone"].(bool)
|
||||
if got != tc.want {
|
||||
t.Errorf("isModelGone(%q) = %v,应为 %v(%s)", tc.msg, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录-剔除-恢复 的闭环,以及**不拿远端目录反推**这条纪律。
|
||||
func TestDeadModelRecordAndPrune(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before := modelIDsFor(t, ps, "zcode")
|
||||
if len(before) == 0 {
|
||||
t.Fatal("zcode 应有兜底模型")
|
||||
}
|
||||
victim := before[0]
|
||||
|
||||
// 记录一条阳性证据 → 该模型必须从列表消失
|
||||
if _, err := ps.Call("channels", "__record_dead_probe",
|
||||
map[string]interface{}{"platform": "zcode", "model_id": victim, "reason": "model not found"}); err != nil {
|
||||
t.Fatalf("记录探针: %v", err)
|
||||
}
|
||||
after := modelIDsFor(t, ps, "zcode")
|
||||
for _, id := range after {
|
||||
if id == victim {
|
||||
t.Errorf("已失效模型 %q 仍出现在列表里(应剔除)", victim)
|
||||
}
|
||||
}
|
||||
if len(after) != len(before)-1 {
|
||||
t.Errorf("剔除后应少一个:前 %d 后 %d", len(before), len(after))
|
||||
}
|
||||
|
||||
// model_clear_dead 应把它恢复回来(用户确认上游又上了 / 误判时用)
|
||||
m, err := ps.Call("channels", "model_clear_dead",
|
||||
map[string]interface{}{"platform": "zcode", "model_id": victim})
|
||||
if err != nil {
|
||||
t.Fatalf("model_clear_dead: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["ok"] != true {
|
||||
t.Fatalf("model_clear_dead 失败: %#v", m)
|
||||
}
|
||||
restored := modelIDsFor(t, ps, "zcode")
|
||||
found := false
|
||||
for _, id := range restored {
|
||||
if id == victim {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("clear 后 %q 应恢复,实得 %v", victim, restored)
|
||||
}
|
||||
}
|
||||
|
||||
// 目录闸门:失败后进入冷却,冷却期内不重复拉(照参考 remote-catalog-gate)。
|
||||
// 判据用冷却状态本身,不依赖真实网络。
|
||||
func TestRemoteCatalogGateCooldown(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 初始不冷却
|
||||
g := func() (bool, bool) {
|
||||
out, err := ps.Call("channels", "__catalog_gate_probe",
|
||||
map[string]interface{}{"platform": "zcode"})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
w, _ := out.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
began, _ := j["began"].(bool)
|
||||
cooling, _ := j["cooling"].(bool)
|
||||
return began, cooling
|
||||
}
|
||||
if _, cooling := g(); cooling {
|
||||
t.Error("初始不该处于冷却")
|
||||
}
|
||||
// 模拟一次失败 → 进冷却
|
||||
if _, err := ps.Call("channels", "__catalog_gate_fail_probe",
|
||||
map[string]interface{}{"platform": "zcode"}); err != nil {
|
||||
t.Fatalf("失败探针: %v", err)
|
||||
}
|
||||
began, cooling := g()
|
||||
if !cooling {
|
||||
t.Error("失败后必须进入冷却(否则会对坏上游反复打 —— 参考点名的首屏卡死)")
|
||||
}
|
||||
if began {
|
||||
t.Error("冷却期内不该再发起拉取")
|
||||
}
|
||||
}
|
||||
|
||||
// modelIDsFor 读某平台当前对外的模型 id 列表。
|
||||
func modelIDsFor(t *testing.T, ps interface {
|
||||
Call(string, string, map[string]interface{}) (interface{}, error)
|
||||
}, plat string) []string {
|
||||
t.Helper()
|
||||
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": plat})
|
||||
if err != nil {
|
||||
t.Fatalf("model_list(%s): %v", plat, err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
var out []string
|
||||
if arr, ok := j["models"].([]interface{}); ok {
|
||||
for _, it := range arr {
|
||||
if mm, ok := it.(map[string]interface{}); ok {
|
||||
if id, ok := mm["id"].(string); ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
67
internal/core/channels_free_flag_test.go
Normal file
67
internal/core/channels_free_flag_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// `isFree` 的判定链(照参考 cline-models.ts)。
|
||||
//
|
||||
// ★ 修的是一个与注释自相矛盾的缺陷:cline 的 3 个模型来自远端 **free 数组**,
|
||||
// 却声明在 `models` 里 ⇒ 被标成收费。用户看不到哪个可以不花钱用。
|
||||
//
|
||||
// 参考的判定链(权威在前):
|
||||
//
|
||||
// ① 远端 free 数组(`recommended-models` 是**唯一权威**的免费集合;
|
||||
// `/api/v1/models` 的 460 个 id 里根本没有 cline-free/*)
|
||||
// ② 命名约定前缀 `cline-free/*`(远端未响应时的兜底)
|
||||
// ③ 声明的 models_free
|
||||
// ④ 能力表的 cost(models.dev)
|
||||
//
|
||||
// ⚠️ 免费是 **OR** 语义:多个来源任一确认免费就够。写成"后者覆盖前者"会让
|
||||
// 「远端说免费、能力表拉不到」退回收费 —— 正是这类静默降级。
|
||||
func TestIsFreeUsesAuthorityChain(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// cline 的免费模型必须被标成免费(远端/free_prefix/声明 任一命中即可)
|
||||
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "cline"})
|
||||
if err != nil {
|
||||
t.Fatalf("model_list: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
got := map[string]bool{}
|
||||
for _, it := range asArrInterface(j["models"]) {
|
||||
if mm, ok := it.(map[string]interface{}); ok {
|
||||
if id, ok := mm["id"].(string); ok {
|
||||
if f, ok := mm["isFree"].(bool); ok {
|
||||
got[id] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `cline-free/*` 前缀命中 ⇒ 必须免费(命名约定兜底)
|
||||
if !got["cline-free/mimo-v2.6-flash"] {
|
||||
t.Errorf("cline-free/* 前缀的模型必须免费(命名约定兜底),实得 %v", got)
|
||||
}
|
||||
// stealth/space-bunny-alpha 来自远端 free 数组,也必须免费
|
||||
if !got["stealth/space-bunny-alpha"] {
|
||||
t.Errorf("远端 free 数组里的模型必须免费,实得 %v", got)
|
||||
}
|
||||
|
||||
// 命名约定必须生效(远端未响应时唯一的兜底依据)
|
||||
probe, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
|
||||
"platform": "cline", "model_id": "cline-free/some-new-model",
|
||||
})
|
||||
pj, _ := probe.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if pj["is_free"] != true {
|
||||
t.Error("远端未响应时,`cline-free/*` 前缀必须能判出免费(这是兜底依据)")
|
||||
}
|
||||
// 反向:收费通道的同名模型**不得**被判免费
|
||||
//(参考点名:免费模型是独立 id,`cline-free/x` 与 `x` 是两个不同模型)
|
||||
probe2, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
|
||||
"platform": "cline", "model_id": "deepseek/deepseek-v4.1-flash",
|
||||
})
|
||||
pj2, _ := probe2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if pj2["is_free"] == true {
|
||||
t.Error("收费通道的模型不得因为前缀规则被判成免费")
|
||||
}
|
||||
}
|
||||
63
internal/core/channels_gate_test.go
Normal file
63
internal/core/channels_gate_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 源注册必须**串行 + 节流**,而不是并发 2、无间隔。
|
||||
//
|
||||
// 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量,
|
||||
// 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:
|
||||
//
|
||||
// GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过
|
||||
// GLM-5.3 74 次 200 21 次重试 6 次最终 429
|
||||
//
|
||||
// 两条措施缺一不可:
|
||||
//
|
||||
// ① **串行**(max_concurrent=1):同一时刻只允许一个上游请求在飞 ——
|
||||
// 并发发起必然浪费一个配额。
|
||||
// ② **最小间隔**(rpm):串行只保证"不重叠",不保证"有间隔" —— 连续放行的
|
||||
// 两次调用可能只隔几十毫秒,仍会撞并发窗口。
|
||||
//
|
||||
// 此前插件注册源时一律 max_concurrent=2 且**从不设 rpm** ⇒ 既会重叠又无间隔。
|
||||
func TestChannelSourcesAreSerialWithThrottle(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "gated",
|
||||
"credential": `{"access_token":"TOK"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 失败: %#v", m)
|
||||
}
|
||||
aid, _ := j["account_id"].(string)
|
||||
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
if !ok {
|
||||
t.Fatal("账号未出源")
|
||||
}
|
||||
// ① 串行
|
||||
if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 {
|
||||
t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"])
|
||||
}
|
||||
// ② 节流(rpm>0 才有间隔)
|
||||
if rpm, _ := toFloat(sm["rpm"]); rpm <= 0 {
|
||||
t.Errorf("源必须设 rpm>0(串行不保证间隔),实得 %v", sm["rpm"])
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
110
internal/core/channels_lobster_claim_test.go
Normal file
110
internal/core/channels_lobster_claim_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 有道每日活动领取是**三步流程**,参考注释点名:无可用活动是**正常业务状态**,
|
||||
// 不能显示成"领取失败"(否则用户会去查网络/账号,方向全错)。
|
||||
// 本判据钉住决策表的四类结果:inactive / already-claimed / success / failed。
|
||||
func TestLobsterActivityClaimDecisionTable(t *testing.T) {
|
||||
type c struct {
|
||||
name string
|
||||
slot string
|
||||
ctx string
|
||||
wantKind string
|
||||
wantCalled bool // 是否应发出 claim 请求
|
||||
}
|
||||
cases := []c{
|
||||
{"槽位不可用→inactive", `{"code":0,"data":{"slotState":"unavailable","activity":{"activityCode":"A1"}}}`, "", "inactive", false},
|
||||
{"无 activityCode→inactive", `{"code":0,"data":{"slotState":"available","activity":{}}}`, "", "inactive", false},
|
||||
{"今天已领→already-claimed", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
|
||||
`{"code":0,"data":{"state":{"claimedToday":true},"actions":["check_in"]}}`, "already-claimed", false},
|
||||
{"无 check_in 动作→inactive", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
|
||||
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["share"]}}`, "inactive", false},
|
||||
{"可领→success", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
|
||||
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["check_in"]}}`, "success", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claimHit := 0
|
||||
var claimBody string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/api/client-activities/slot":
|
||||
fmt.Fprint(w, tc.slot)
|
||||
case r.URL.Path == "/api/client-activities/A1/context":
|
||||
if tc.ctx == "" {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, tc.ctx)
|
||||
case r.URL.Path == "/api/client-activities/A1/actions/check_in":
|
||||
claimHit++
|
||||
b := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(b)
|
||||
claimBody = string(b)
|
||||
fmt.Fprint(w, `{"code":0,"data":{"result":{"creditsGranted":30}}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把账号基址指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "lobsterai",
|
||||
"lobster_activity_base": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 存入凭据(claim 需要结构化 credential 对象)
|
||||
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"platform": "lobsterai", "credential": string(cred), "account": "t",
|
||||
})
|
||||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: string(body),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: `{"platform":"lobsterai"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim_daily: %v", err)
|
||||
}
|
||||
j, _ := out.JSON.(map[string]interface{})
|
||||
oc, _ := j["outcome"].(map[string]interface{})
|
||||
if oc == nil {
|
||||
t.Fatalf("应返回 outcome 判别联合,实得 %v", j)
|
||||
}
|
||||
if k, _ := oc["kind"].(string); k != tc.wantKind {
|
||||
t.Fatalf("kind 应为 %s,实得 %v(%v)", tc.wantKind, oc["kind"], oc["message"])
|
||||
}
|
||||
if (claimHit > 0) != tc.wantCalled {
|
||||
t.Errorf("claim 请求次数 = %d,期望发出=%v", claimHit, tc.wantCalled)
|
||||
}
|
||||
if tc.wantCalled && claimBody == "" {
|
||||
t.Error("claim 请求体不应为空(要带 configRevision 与幂等键)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
75
internal/core/channels_lobster_exchange_test.go
Normal file
75
internal/core/channels_lobster_exchange_test.go
Normal file
@ -0,0 +1,75 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"llmsproxy/internal/lua"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLobsterExchangePreservesIdentity(t *testing.T) {
|
||||
var calls int
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
var b map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if b["authCode"] != "quote\"&code" || len(b["uuid"]) != 36 || b["firstKeyfrom"] == "" || b["firstKeyfrom"] == "cli" {
|
||||
t.Errorf("invalid exchange fields: %#v", b)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" || r.Header.Get("User-Agent") != "LobsterAI/0.1.0" {
|
||||
t.Error("wrong headers")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"code":0,"data":{"accessToken":"at-test","refreshToken":"rt-test","expiresIn":3600,"user":{"id":"uid1","userId":"account1","nickname":"test"}}}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The fixture changes only the transport destination, not the upstream contract.
|
||||
if _, err = ps.Call("channels", "set_endpoints", map[string]interface{}{"platform": "lobsterai", "callback": map[string]interface{}{"token_url": ts.URL}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
|
||||
req := &lua.HandlerRequest{Method: "GET", Query: map[string]string{"state": nonce, "authCode": "quote\"&code"}}
|
||||
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Status != 200 {
|
||||
t.Fatalf("callback: %#v", out)
|
||||
}
|
||||
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
|
||||
if err != nil || !ok {
|
||||
t.Fatal("missing credential", err)
|
||||
}
|
||||
var cred map[string]interface{}
|
||||
if err = json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "latest_keyfrom", "expires_at", "uid", "user_id"} {
|
||||
if cred[k] == nil || cred[k] == "" {
|
||||
t.Errorf("missing %s", k)
|
||||
}
|
||||
}
|
||||
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Status != 409 || calls != 1 {
|
||||
t.Fatalf("replay accepted: status=%d calls=%d", out.Status, calls)
|
||||
}
|
||||
}
|
||||
165
internal/core/channels_lobster_loopback_test.go
Normal file
165
internal/core/channels_lobster_loopback_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 有道登录闭环的判据:portal 会把授权重定向回 **本机 loopback 接收器**
|
||||
// (127.0.0.1:{port}/auth/callback 精确路径),而不是用户机器的 localhost、
|
||||
// 也不是本插件的 /api/plugins/http 挂载点。远程用户在服务端浏览器里完成后,
|
||||
// 重定向落本机端口,接收器写文件,poll_login 读它完成交换。
|
||||
//
|
||||
// 参考 lobsterai-oauth.ts 明确:redirect_uri 必须该形态(登录页会校验),
|
||||
// state 不匹配必须拒绝(400),交换体含 authCode/firstKeyfrom/latestKeyfrom/
|
||||
// uuid/version 五字段,凭据必须带 uuid+first_keyfrom(否则续期失败)。
|
||||
func TestLobsterLoopbackClosedLoop(t *testing.T) {
|
||||
var exch int
|
||||
var gotVersion string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
exch++
|
||||
var b map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
if b["authCode"] != "AC-1" {
|
||||
t.Errorf("exchange authCode = %q", b["authCode"])
|
||||
}
|
||||
gotVersion = b["version"]
|
||||
if b["firstKeyfrom"] == "" || b["uuid"] == "" {
|
||||
t.Errorf("exchange 缺字段: %#v", b)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"code":0,"data":{"accessToken":"AT-1","refreshToken":"RT-1","expiresIn":3600,"user":{"id":"UID1"}}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// 版本号是 exchange 的必填字段,**不能写死**:2026-10-13 实测上游已是
|
||||
// 2026.9.23,而参考的兜底常量还停在 2026.9.4。此处假上游返回真形态
|
||||
// (非统一信封,值在 data.value.version)。
|
||||
ver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"data":{"value":{"version":"2026.9.23","date":"2026-9-23"}}}`)
|
||||
}))
|
||||
defer ver.Close()
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "status"}, false)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "lobsterai",
|
||||
"callback": map[string]interface{}{"token_url": ts.URL},
|
||||
"client_version": map[string]interface{}{"url": ver.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
sess := j["session"].(map[string]interface{})
|
||||
nonce := sess["nonce"].(string)
|
||||
sid := sess["id"].(string)
|
||||
redirectURI, _ := j["redirect_uri"].(string)
|
||||
if !strings.HasPrefix(redirectURI, "http://127.0.0.1:") || !strings.HasSuffix(redirectURI, "/auth/callback") {
|
||||
t.Fatalf("redirect_uri 必须是 127.0.0.1:{port}/auth/callback,实得 %q", redirectURI)
|
||||
}
|
||||
url, _ := j["url"].(string)
|
||||
if !strings.Contains(url, "/portal#/login?") {
|
||||
t.Fatalf("授权页应是 /portal#/login?…,实得 %q", url)
|
||||
}
|
||||
if !strings.Contains(url, "source=electron") {
|
||||
t.Fatalf("授权页缺 source=electron: %q", url)
|
||||
}
|
||||
|
||||
t.Run("行1 state 不匹配被拒", func(t *testing.T) {
|
||||
resp, err := http.Get(redirectURI + "?code=AC-1&state=WRONG")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 400 {
|
||||
t.Fatalf("state 不匹配应 400,实得 %d", resp.StatusCode)
|
||||
}
|
||||
if exch != 0 {
|
||||
t.Fatal("state 不匹配不应触发交换")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 正确回调完成登录并落完整凭据", func(t *testing.T) {
|
||||
resp, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("合法回调应 200,实得 %d", resp.StatusCode)
|
||||
}
|
||||
// 接收器写文件后 poll_login 读它完成交换(可能需要一拍)
|
||||
var st map[string]interface{}
|
||||
for i := 0; i < 20; i++ {
|
||||
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st = o.JSON.(map[string]interface{})
|
||||
if st["state"] != "pending" {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("应完成登录,实得 %v", st)
|
||||
}
|
||||
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("凭据未落库: %v", err)
|
||||
}
|
||||
var cred map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "expires_at", "uid"} {
|
||||
if cred[k] == nil || cred[k] == "" {
|
||||
t.Errorf("凭据缺 %s(有道的 uuid/first_keyfrom 丢了就续期失败): %s", k, raw)
|
||||
}
|
||||
}
|
||||
if cred["access_token"] != "AT-1" {
|
||||
t.Fatalf("access_token = %v", cred["access_token"])
|
||||
}
|
||||
// 版本号必须来自远端(2026.9.23),不能是写死的兜底 2026.9.4
|
||||
if gotVersion != "2026.9.23" {
|
||||
t.Fatalf("exchange version 应为远端 2026.9.23(写死 2026.9.4 是 bug),实得 %q", gotVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 接收器用完即收", func(t *testing.T) {
|
||||
// 完成后监听应已关闭(连接被拒)
|
||||
_, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
|
||||
if err == nil {
|
||||
t.Fatal("接收器应在完成后关闭")
|
||||
} else if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "connect") {
|
||||
t.Logf("关闭判定: %v", err)
|
||||
}
|
||||
})
|
||||
_ = fmt.Sprint
|
||||
}
|
||||
177
internal/core/channels_loomy_test.go
Normal file
177
internal/core/channels_loomy_test.go
Normal file
@ -0,0 +1,177 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// loomyHarness:Loomy 微信扫码长轮询的测试台。
|
||||
//
|
||||
// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"):
|
||||
//
|
||||
// 408 = 等待扫码;404 = 已扫码待确认(继续轮询);
|
||||
// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效
|
||||
type loomyHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
polls int32
|
||||
confirmAt int32
|
||||
gotAuth string
|
||||
}
|
||||
|
||||
func newLoomyHarness(t *testing.T) *loomyHarness {
|
||||
t.Helper()
|
||||
h := &loomyHarness{t: t, confirmAt: 3}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"):
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
// 夹具必须用**真实微信页面的形态**:uuid 内嵌在
|
||||
// <img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>。
|
||||
// 用别的形态(如 var uuid = "...")测出来的正则不是生产要用的那条。
|
||||
fmt.Fprint(w, `<html><img class="js_qrcode_img" src="/connect/qrcode/UUID-TEST-1"/></html>`)
|
||||
case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"):
|
||||
n := atomic.AddInt32(&h.polls, 1)
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if n == 1 {
|
||||
fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码
|
||||
return
|
||||
}
|
||||
if n < atomic.LoadInt32(&h.confirmAt) {
|
||||
fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认
|
||||
case r.URL.Path == "/login/thirdAccount/bind/auth":
|
||||
h.gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "loomy",
|
||||
"wechat": map[string]interface{}{
|
||||
"auth_url": h.ts.URL + "/connect/qrconnect?state={state}",
|
||||
"qr_url": h.ts.URL + "/connect/qrcode/{uuid}",
|
||||
"poll_url": h.ts.URL + "/connect/l/qrconnect",
|
||||
"account_base": h.ts.URL,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *loomyHarness) begin() (map[string]interface{}, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
func (h *loomyHarness) pollOnce(sid string) map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。
|
||||
func TestLoomyWechatPollBehavior(t *testing.T) {
|
||||
h := newLoomyHarness(t)
|
||||
|
||||
t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
j, _ := h.begin()
|
||||
qr, _ := j["qr_url"].(string)
|
||||
if !strings.Contains(qr, "UUID-TEST-1") {
|
||||
t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.confirmAt, 99)
|
||||
_, sid := h.begin()
|
||||
// 第一拍 408
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("408 应 pending, 实得 %v", st["state"])
|
||||
}
|
||||
// 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里)
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "pending" {
|
||||
t.Fatalf("404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.confirmAt, 3)
|
||||
_, sid := h.begin()
|
||||
h.pollOnce(sid) // 408
|
||||
h.pollOnce(sid) // 404
|
||||
st := h.pollOnce(sid) // 405
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"])
|
||||
}
|
||||
// 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer
|
||||
if !strings.HasPrefix(h.gotAuth, "account ") {
|
||||
t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth)
|
||||
}
|
||||
if !strings.Contains(h.gotAuth, ":") {
|
||||
t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth)
|
||||
}
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:loomy")
|
||||
if !ok || !strings.Contains(v, "RCODE-1") {
|
||||
t.Fatalf("凭据应含 rcode, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) {
|
||||
for _, tc := range []struct{ errcode, want string }{
|
||||
{"403", "取消"}, {"402", "失效"},
|
||||
} {
|
||||
hh := newLoomyHarness(t)
|
||||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") {
|
||||
fmt.Fprint(w, `<img src="/connect/qrcode/U">`)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode)
|
||||
})
|
||||
_, sid := hh.begin()
|
||||
st := hh.pollOnce(sid)
|
||||
if st["state"] != "failed" {
|
||||
t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
201
internal/core/channels_models_remote_test.go
Normal file
201
internal/core/channels_models_remote_test.go
Normal file
@ -0,0 +1,201 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 远端模型目录的判据:**远端为主 + 兜底补位**(照参考 reconcileWithFallback)。
|
||||
//
|
||||
// 参考明确:"正确做法是**合并**,而不是「远端失败就用兜底」的简单回退"
|
||||
// (cline-models.ts)。故这里逐条锁定:
|
||||
// - 远端有的以远端为准(顺序也照远端)
|
||||
// - 远端没有的用兜底表补位
|
||||
// - 拉取失败**不清空**既有缓存(宁可留旧目录也别让平台变空)
|
||||
// - 平台声明的真实目录 key 必须在上游源里(占位名会让选模型调不通)
|
||||
type modelsHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
mu sync.Mutex
|
||||
body string
|
||||
code int
|
||||
hits int
|
||||
}
|
||||
|
||||
func newModelsHarness(t *testing.T, platform, body string) *modelsHarness {
|
||||
t.Helper()
|
||||
h := &modelsHarness{t: t, body: body, code: 200}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
h.hits++
|
||||
code, body := h.code, h.body
|
||||
h.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
// 说明:测试上游是 127.0.0.1(loopback),出站白名单默认允许,
|
||||
// 不需要额外授权 —— 也就不需要碰内核的授权 API。
|
||||
// 把该平台的远端端点指到假上游
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": platform,
|
||||
"models_remote": map[string]interface{}{
|
||||
"endpoint0": h.ts.URL,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Logf("set_endpoints(models_remote) 未支持: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// modelIDs 从 model_list 的 Call 返回值({json:{models:[...]}})取 id 列表。
|
||||
func modelIDs(t *testing.T, got interface{}) []string {
|
||||
t.Helper()
|
||||
j := got.(map[string]interface{})["json"].(map[string]interface{})
|
||||
ms, _ := j["models"].([]interface{})
|
||||
ids := make([]string, 0, len(ms))
|
||||
for _, e := range ms {
|
||||
ids = append(ids, e.(map[string]interface{})["id"].(string))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestRemoteModelCatalogMergesWithFallback(t *testing.T) {
|
||||
// zcode:远端 builtinModels 是**对象**(键为序号),且远端/兜底有重叠
|
||||
body := `{"data":{"builtinModels":{"0":{"modelId":"GLM-5.3"},"1":{"modelId":"GLM-5.3-Flash"},"2":{"modelId":"NEW-MODEL-X"}}}}`
|
||||
h := newModelsHarness(t, "zcode", body)
|
||||
|
||||
t.Run("行1 远端拉到就用远端并保留兜底独有的", func(t *testing.T) {
|
||||
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]interface{})["json"].(map[string]interface{})
|
||||
res := m["results"].([]interface{})[0].(map[string]interface{})
|
||||
if res["ok"] != true {
|
||||
t.Fatalf("刷新应成功, 实得 %v", res)
|
||||
}
|
||||
// NEW-MODEL-X 只由远端下发,必须出现(证明用的不是硬编码表)
|
||||
ml, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := modelIDs(t, ml)
|
||||
joined := strings.Join(ids, ",")
|
||||
if !strings.Contains(joined, "NEW-MODEL-X") {
|
||||
t.Fatalf("只由远端下发的模型必须出现(否则是硬编码表): %v", ids)
|
||||
}
|
||||
if !strings.Contains(joined, "GLM-5.3") {
|
||||
t.Fatalf("兜底/远端共有项应保留: %v", ids)
|
||||
}
|
||||
// 远端与兜底重叠项不得重复
|
||||
seen := map[string]int{}
|
||||
for _, id := range ids {
|
||||
seen[id]++
|
||||
if seen[id] > 1 {
|
||||
t.Fatalf("模型 %s 重复出现(合并应去重): %v", id, ids)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 拉取失败不清空既有缓存", func(t *testing.T) {
|
||||
// 先成功一次建立缓存
|
||||
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 再让上游 500
|
||||
h.mu.Lock()
|
||||
h.code = 500
|
||||
h.body = `{}`
|
||||
h.mu.Unlock()
|
||||
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := got.(map[string]interface{})["json"].(map[string]interface{})["results"].([]interface{})[0].(map[string]interface{})
|
||||
if res["ok"] == true {
|
||||
t.Fatalf("上游 500 时刷新应报失败, 实得 %v", res)
|
||||
}
|
||||
ml, _ := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
|
||||
ids := modelIDs(t, ml)
|
||||
if !strings.Contains(strings.Join(ids, ","), "NEW-MODEL-X") {
|
||||
t.Fatalf("拉取失败必须保留既有目录(不能让平台变空): %v", ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPlatformModelsAreRealCatalogKeys 锁定 12 平台的模型名**不是编的占位名**。
|
||||
// 此前 qoder/zcode/loomy/raccoon 用的是 "*-default"(我编的),上游不认 ——
|
||||
// 后果是这些平台登录成功后选任何模型都调不通,而测试全绿(它测的就是我编的名字)。
|
||||
func TestPlatformModelsAreRealCatalogKeys(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ps.Call("channels", "provider_status", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plats := got.(map[string]interface{})["json"].(map[string]interface{})["statuses"].([]interface{})
|
||||
if len(plats) != 12 {
|
||||
t.Fatalf("应有 12 个真实平台, 实得 %d", len(plats))
|
||||
}
|
||||
realQoder := []string{"qfmodel", "qmodel_38max", "kmodel_latest", "dmodel"}
|
||||
sawQoder := 0
|
||||
for _, e := range plats {
|
||||
p := e.(map[string]interface{})
|
||||
id := p["id"].(string)
|
||||
ml, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": id})
|
||||
if err != nil {
|
||||
t.Fatalf("平台 %s model_list: %v", id, err)
|
||||
}
|
||||
ids := modelIDs(t, ml)
|
||||
if len(ids) == 0 {
|
||||
t.Fatalf("平台 %s 无模型", id)
|
||||
}
|
||||
for _, mid := range ids {
|
||||
// 占位名特征:以 -default 结尾
|
||||
if strings.HasSuffix(mid, "-default") {
|
||||
t.Fatalf("平台 %s 的模型 %q 是编的占位名(上游不认,选了调不通)", id, mid)
|
||||
}
|
||||
}
|
||||
if id == "qoder" {
|
||||
j := map[string]bool{}
|
||||
for _, mid := range ids {
|
||||
j[mid] = true
|
||||
}
|
||||
for _, want := range realQoder {
|
||||
if j[want] {
|
||||
sawQoder++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sawQoder == 0 {
|
||||
t.Fatal("qoder 应含参考的真实目录 key(如 qfmodel),而非编造的占位名")
|
||||
}
|
||||
}
|
||||
@ -260,6 +260,38 @@ func (h *buddyHarness) begin() (map[string]interface{}, string) {
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。
|
||||
// 这正是用户报障("登录了但无法获取账号信息")的那条路径。
|
||||
func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) {
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string), aid
|
||||
}
|
||||
|
||||
func (h *buddyHarness) accountList() []interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
accts, _ := m["accounts"].([]interface{})
|
||||
return accts
|
||||
}
|
||||
func (h *buddyHarness) poll(id string) map[string]interface{} {
|
||||
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": id},
|
||||
@ -287,9 +319,17 @@ func TestBuddyStatePollBehavior(t *testing.T) {
|
||||
if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" {
|
||||
t.Fatalf("次拍应 completed+buddy-dev: %v", j)
|
||||
}
|
||||
// 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token:
|
||||
// 必须带 refresh_token,否则 refreshable=false、无法静默续期。
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:buddy")
|
||||
if v != "BD-AT-1" {
|
||||
t.Fatalf("凭据应 BD-AT-1, 实得 %q", v)
|
||||
if !strings.Contains(v, `"access_token":"BD-AT-1"`) {
|
||||
t.Fatalf("凭据应含 access_token, 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, "buddy-dev") {
|
||||
t.Fatalf("凭据应回填昵称, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) {
|
||||
@ -303,6 +343,46 @@ func TestBuddyStatePollBehavior(t *testing.T) {
|
||||
t.Fatalf("account 失败不应阻塞: %v", j)
|
||||
}
|
||||
})
|
||||
|
||||
// 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。
|
||||
// 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`,
|
||||
// 而池账号 credentialRef 是 `channels:buddy:<aid>` → account_list 报
|
||||
// has_credential=false、credits 报「无凭据」——即用户报障的
|
||||
// 「登录了但无法获取账号信息」。
|
||||
t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.tokStep, 0)
|
||||
_, sid, aid := h.beginPool()
|
||||
h.poll(sid) // 首拍 11217
|
||||
j := h.poll(sid)
|
||||
if j["state"] != "completed" {
|
||||
t.Fatalf("池路径登录应完成: %v", j)
|
||||
}
|
||||
// ① 凭据必须落在池账号的 ref 上
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("池凭据应是完整 JSON: %q", v)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true(UI 读的就是这个字段)
|
||||
found := false
|
||||
for _, raw := range h.accountList() {
|
||||
a, _ := raw.(map[string]interface{})
|
||||
if a["id"] == aid {
|
||||
found = true
|
||||
if a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
if a["refreshable"] != true {
|
||||
t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("池账号 %s 未出现在 account_list", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
@ -27,6 +27,9 @@ func TestChannelsPluginEndToEnd(t *testing.T) {
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatalf("加载 channels: %v", err)
|
||||
}
|
||||
// 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码
|
||||
// (否则会出现在生产 UI 的平台列表里)。
|
||||
injectTestPlatforms(t, ps, examplePlatformFixture)
|
||||
|
||||
// 1. 平台清单可用(未授权任何东西也应能列出)
|
||||
out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{
|
||||
@ -89,7 +92,7 @@ func TestChannelsPluginEndToEnd(t *testing.T) {
|
||||
}
|
||||
// 且不得出现在 state 里
|
||||
if stateHasSecret(ps, "channels", "tok-1") {
|
||||
t.Fatal("★ 凭据泄漏进了 plugin.state")
|
||||
t.Fatal("凭据泄漏进了 plugin.state")
|
||||
}
|
||||
|
||||
// 5. 运维授权后,重新接入应当真的把源注册进内核
|
||||
@ -239,3 +242,197 @@ func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool {
|
||||
}
|
||||
return contains(string(b), secret)
|
||||
}
|
||||
|
||||
// examplePlatformFixture 是端到端测试用的占位平台(对应此前内联在生产源码里
|
||||
// 的 "example" 平台)。它经 inject_platforms 注入,且**只在测试进程内存在** ——
|
||||
// 生产插件的 PLATFORMS 只含真实厂商。
|
||||
const examplePlatformFixture = `[
|
||||
{
|
||||
"id": "example",
|
||||
"name": "Example 平台(测试夹具)",
|
||||
"flow": "link-poll",
|
||||
"login_url": "https://auth.example.com/device",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"adapter": "openai",
|
||||
"models": ["example-chat", "example-reason"],
|
||||
"checkin": {"url": "https://api.example.com/v1/api/daily/claim", "method": "POST", "body": "{}"}
|
||||
}
|
||||
]`
|
||||
|
||||
// injectTestPlatforms 把测试夹具注入插件(运行时,不落盘)。
|
||||
func injectTestPlatforms(t *testing.T, ps *lua.Plugins, fixtureJSON string) {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platforms":` + fixtureJSON + `}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inject_platforms: %v", err)
|
||||
}
|
||||
m, _ := out.JSON.(map[string]interface{})
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("注入测试平台失败: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectPlatformsRejectsBadInput 锁定注入机制的三条边界:
|
||||
// 1. 不得覆盖内建平台(注入不能劫持真实厂商)
|
||||
// 2. 重复 id 拒绝
|
||||
// 3. 空数组 = 清空注入(夹具不跨测试残留)
|
||||
//
|
||||
// 这个机制存在的理由:夹具内联进生产源码会让它们出现在生产 UI 的平台列表里
|
||||
// (真实踩过 —— 16 个平台里有 4 个是夹具)。
|
||||
func TestInjectPlatformsRejectsBadInput(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
call := func(body string) map[string]interface{} {
|
||||
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
|
||||
Method: "POST", Body: body, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inject_platforms(%s): %v", body, err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
platforms := func() []interface{} {
|
||||
out, _ := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
return out.JSON.(map[string]interface{})["platforms"].([]interface{})
|
||||
}
|
||||
|
||||
base := len(platforms())
|
||||
if base == 0 {
|
||||
t.Fatal("生产目录应有真实厂商平台")
|
||||
}
|
||||
|
||||
// 注入一个合法夹具 → 列表 +1
|
||||
if m := call(`{"platforms":[{"id":"fixture-a","name":"A","flow":"paste","base_url":"https://a.example/v1"}]}`); m["ok"] != true {
|
||||
t.Fatalf("合法注入应成功: %v", m)
|
||||
}
|
||||
if got := len(platforms()); got != base+1 {
|
||||
t.Fatalf("注入后平台数 = %d, 期望 %d", got, base+1)
|
||||
}
|
||||
|
||||
// 1. 覆盖内建平台 → 拒绝
|
||||
if m := call(`{"platforms":[{"id":"buddy","name":"劫持"}]}`); m["error"] == nil {
|
||||
t.Fatal("覆盖内建平台必须被拒绝")
|
||||
}
|
||||
// 2. 重复 id → 拒绝
|
||||
if m := call(`{"platforms":[{"id":"fixture-b"},{"id":"fixture-b"}]}`); m["error"] == nil {
|
||||
t.Fatal("重复 id 必须被拒绝")
|
||||
}
|
||||
// 无 id → 拒绝
|
||||
if m := call(`{"platforms":[{"name":"no-id"}]}`); m["error"] == nil {
|
||||
t.Fatal("缺 id 必须被拒绝")
|
||||
}
|
||||
|
||||
// 3. 空数组 = 清空注入,回到基线
|
||||
if m := call(`{"platforms":[]}`); m["ok"] != true {
|
||||
t.Fatalf("清空应成功: %v", m)
|
||||
}
|
||||
if got := len(platforms()); got != base {
|
||||
t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsBrowserStackDeps 锁定浏览器栈的依赖预检能力。
|
||||
//
|
||||
// 这个能力此前**不存在**:代码里写着「运行 deploy 脚本一键安装」,但安装机制
|
||||
// 根本没有(deploy.sh 里零浏览器栈内容),于是 VNC 兜底在缺依赖的机器上只会
|
||||
// 报一句含糊的"进程启动失败"。现在如实报告缺什么、装哪个包。
|
||||
func TestChannelsBrowserStackDeps(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deps_status: %v", err)
|
||||
}
|
||||
m, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("deps_status 应返回对象: %#v", out.JSON)
|
||||
}
|
||||
if _, has := m["ok"]; !has {
|
||||
t.Fatal("deps_status 必须报告 ok")
|
||||
}
|
||||
if _, has := m["missing"]; !has {
|
||||
t.Fatal("deps_status 必须报告 missing 清单")
|
||||
}
|
||||
if m["ok"] == false {
|
||||
hint, _ := m["install_hint"].(string)
|
||||
if hint == "" {
|
||||
t.Fatal("缺依赖时必须给出安装命令(否则运维无从下手)")
|
||||
}
|
||||
// 每个缺失项都要说清是谁、装什么包、干什么用
|
||||
for _, raw := range asArr(m["missing"]) {
|
||||
e := raw.(map[string]interface{})
|
||||
for _, k := range []string{"bin", "pkg", "why"} {
|
||||
if s, _ := e[k].(string); s == "" {
|
||||
t.Fatalf("缺少字段 %s: %v", k, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("本机缺依赖(预期:CI 容器通常没有浏览器栈): %v", m["missing"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsBrowserEndpointsRequireSession 浏览器栈的三个端点在没有会话时
|
||||
// 必须**如实报错**,而不是假装成功或 panic。
|
||||
func TestChannelsBrowserEndpointsRequireSession(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 无会话时 collect 必须报错
|
||||
out, err := ps.InvokeHandler("channels", "collect_credentials", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collect_credentials: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["error"] == nil {
|
||||
t.Fatal("无浏览器会话时 collect_credentials 必须报错")
|
||||
}
|
||||
|
||||
// status 如实说 running=false
|
||||
out, err = ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("browser_status: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["running"] != false {
|
||||
t.Fatal("无会话时 running 应为 false")
|
||||
}
|
||||
|
||||
// stop 幂等:无会话时也返回 ok
|
||||
out, err = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
if out.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Fatal("stop_browser 应幂等成功")
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
|
||||
@ -120,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", `{}`)
|
||||
@ -256,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}`)
|
||||
})
|
||||
@ -320,3 +345,95 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考
|
||||
// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后
|
||||
// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。
|
||||
func TestBuddyBillingHeadersMatchReference(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
var got map[string]string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = map[string]string{}
|
||||
for k := range r.Header {
|
||||
got[k] = r.Header.Get(k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":{"totalCredit":42}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// 把 buddy 的余额/签到端点指到本地假上游
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("account_add: %v", m)
|
||||
}
|
||||
invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("余额请求未发出")
|
||||
}
|
||||
// 参考 checkinHeaders 的必备身份头(缺一个上游就 401)
|
||||
want := map[string]string{
|
||||
"Authorization": "Bearer BD-AT",
|
||||
"X-Domain": "copilot.tencent.com",
|
||||
"X-Product": "SaaS",
|
||||
"X-Product-Code": "codebuddy",
|
||||
"User-Agent": "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径:
|
||||
// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同,
|
||||
// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain ——
|
||||
// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。
|
||||
func TestBuddyBalanceParseDoubleNested(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
|
||||
// 两个有效包 + 一个失效包(ExpiredTime 在 2020 年)
|
||||
body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[
|
||||
{"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"},
|
||||
{"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"}
|
||||
]}}}}`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
invoke(t, ps, "set_endpoints", fmt.Sprintf(
|
||||
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
|
||||
invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
|
||||
|
||||
j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
var row map[string]interface{}
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
bal, _ := row["balance"].(map[string]interface{})
|
||||
if bal == nil {
|
||||
t.Fatalf("应解析出余额, 实得 error=%v", row["error"])
|
||||
}
|
||||
// 100.5 + 55.17 = 155.67(失效包的 500 不计入)
|
||||
if got, _ := bal["total"].(float64); got != 155.67 {
|
||||
t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"])
|
||||
}
|
||||
|
||||
// 单层 data(缺 Response 层)必须判为解析失败,不能当 0
|
||||
body = `{"code":0,"data":{"TotalDosage":609}}`
|
||||
j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
|
||||
for _, a := range asArr(j["accounts"]) {
|
||||
row = a.(map[string]interface{})
|
||||
}
|
||||
if row["balance"] != nil {
|
||||
t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row)
|
||||
}
|
||||
}
|
||||
|
||||
86
internal/core/channels_private_adapter_wiring_test.go
Normal file
86
internal/core/channels_private_adapter_wiring_test.go
Normal file
@ -0,0 +1,86 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 三个"私有协议"平台的源注册必须引用**插件自装**的适配器。
|
||||
//
|
||||
// 为什么单独测这一条:适配器装上了 ≠ 源用了它。"装了但源还指向 openai"
|
||||
// 是这类改动最可能的漏配,且**不会报错** —— 只是签名/信封不生效、上游返回
|
||||
// 流内错误或 401,看起来像"平台不稳定"。
|
||||
//
|
||||
// 三个平台的失败模式各不相同,但根因是同一个(平台协议差异被当成适配器名差异):
|
||||
//
|
||||
// codearts → 华为云 AK/SK 签名
|
||||
// trae → SOLO 载荷格式(config_name/function)
|
||||
// gemini → 私有 /v1internal: 信封 + 身份五头
|
||||
func TestPrivateProtocolSourcesUsePluginAdapters(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
|
||||
cases := []struct {
|
||||
platform string
|
||||
wantAdapter string
|
||||
wantBaseHas string
|
||||
credential string
|
||||
}{
|
||||
{"codearts", "codearts",
|
||||
"snap-access.cn-north-4.myhuaweicloud.com",
|
||||
`{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`},
|
||||
{"trae", "channels-trae",
|
||||
"trae-api-cn.mchost.guru",
|
||||
`{"access_token":"TRAE-TOK","refresh_token":"RT"}`},
|
||||
{"gemini", "channels-gemini",
|
||||
"cloudcode-pa.googleapis.com",
|
||||
`{"access_token":"GEM-TOK","refresh_token":"GRT"}`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.platform, func(t *testing.T) {
|
||||
m, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": tc.platform, "credential": tc.credential,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
if j == nil || j["error"] != nil {
|
||||
t.Fatalf("account_add 报错: %#v", m)
|
||||
}
|
||||
aid, _ := j["account_id"].(string)
|
||||
|
||||
srcs := listSourcesViaKernel(t, c)
|
||||
sm, ok := srcHasAccount(srcs, aid)
|
||||
if !ok {
|
||||
names := []string{}
|
||||
for _, x := range srcs {
|
||||
names = append(names, x["name"].(string))
|
||||
}
|
||||
t.Fatalf("%s 账号加了却不出源(现有源: %v)—— 源注册失败通常是"+
|
||||
"凭据形状不符或内核权限未授", tc.platform, names)
|
||||
}
|
||||
ad, _ := sm["adapter"].(string)
|
||||
if ad != tc.wantAdapter {
|
||||
t.Errorf("源 adapter = %q,应为插件自装的 %q"+
|
||||
"(否则平台协议转换不生效,且不报错)", ad, tc.wantAdapter)
|
||||
}
|
||||
bu, _ := sm["base_url"].(string)
|
||||
if !strings.Contains(bu, tc.wantBaseHas) {
|
||||
t.Errorf("base_url = %q,应含 %q", bu, tc.wantBaseHas)
|
||||
}
|
||||
// 模型必须进源(否则账号看起来接入了但一个模型都调不到)
|
||||
var nModels int
|
||||
switch mv := sm["models"].(type) {
|
||||
case []string:
|
||||
nModels = len(mv)
|
||||
case []interface{}:
|
||||
nModels = len(mv)
|
||||
}
|
||||
if nModels == 0 {
|
||||
t.Errorf("%s 源没有任何模型(%T)", tc.platform, sm["models"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
88
internal/core/channels_qoder_adapter_test.go
Normal file
88
internal/core/channels_qoder_adapter_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// qoderReason 调真实的 transform_error 钩子(不自己重算)。
|
||||
func qoderReason(t *testing.T, vm *lua.VM, status int, body string) string {
|
||||
t.Helper()
|
||||
reason, ok, err := vm.TransformError("channels-qoder", status, body)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_error 调用失败: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("channels-qoder 适配器未装载或未定义 transform_error(body=%s)", body)
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
// Qoder 的错误信封是**双层编码**的,且两个业务码语义**相反**:
|
||||
//
|
||||
// 10605 排队 → 暂时受阻,等一下就好
|
||||
// 110 额度 → 当天耗尽,重试无用(参考实测:原先归成通用服务端错误 → 白重试 5 次)
|
||||
//
|
||||
// 通用 openai 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` ——
|
||||
// 用户看不懂是排队还是额度用完。故用**插件自装的 channels-qoder**,
|
||||
// 厂商码值留在插件域(内核零厂商名)。
|
||||
func TestQoderAdapterParsesQueueAndBilling(t *testing.T) {
|
||||
c, _ := poolCore(t)
|
||||
vm := c.VM()
|
||||
|
||||
// ① 排队:外层 code=10605 + 内层 JSON 字符串(参考实测的真实形状)
|
||||
out := qoderReason(t, vm, 403,
|
||||
`{"code":"10605","message":"{\"isQueued\":true,\"queueCount\":12,\"retryAfterSeconds\":30}"}`)
|
||||
if !strings.Contains(out, "排队") {
|
||||
t.Errorf("10605 必须说成排队,实得 %q", out)
|
||||
}
|
||||
// 等待时长与队列长度要从**内层**取到(只读顶层永远拿不到)
|
||||
if !strings.Contains(out, "30") {
|
||||
t.Errorf("应从内层取到 retryAfterSeconds=30,实得 %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "12") {
|
||||
t.Errorf("应从内层取到 queueCount=12,实得 %q", out)
|
||||
}
|
||||
|
||||
// ② SSE 错误帧形态:**内层没有 code 字段**,只有排队标志。
|
||||
// ⚠️ 参考第一版要求「必须命中 code 才算排队」,导致这一形态被判成非排队、
|
||||
// SSE 通道的修复静默失效(探针显示 sleep=0)。这里必须能识别。
|
||||
out2 := qoderReason(t, vm, 200,
|
||||
`{"message":"{\"isQueued\":true,\"queueCount\":3,\"retryAfterSeconds\":8}"}`)
|
||||
if !strings.Contains(out2, "排队") {
|
||||
t.Errorf("内层无 code 也必须识别为排队(这是参考踩过的坑),实得 %q", out2)
|
||||
}
|
||||
if !strings.Contains(out2, "8") {
|
||||
t.Errorf("应取到内层 retryAfterSeconds=8,实得 %q", out2)
|
||||
}
|
||||
|
||||
// ③ 额度耗尽:必须与排队**分开说**,且明确"重试无用"
|
||||
out3 := qoderReason(t, vm, 403, `{"code":"110","message":"Billing daily count exceeded"}`)
|
||||
if !strings.Contains(out3, "额度") {
|
||||
t.Errorf("110 必须说成额度耗尽,实得 %q", out3)
|
||||
}
|
||||
if strings.Contains(out3, "排队") {
|
||||
t.Errorf("110 绝不能被说成排队(两者语义相反),实得 %q", out3)
|
||||
}
|
||||
if !strings.Contains(out3, "重试无用") {
|
||||
t.Errorf("额度耗尽要明确告知重试无用,实得 %q", out3)
|
||||
}
|
||||
|
||||
// ④ 泛词不能误伤:正文里讨论"配额/余额"不该被当成额度错误
|
||||
//(关键词必须窄 —— 宽词会把模型正常输出误判成故障)
|
||||
out4 := qoderReason(t, vm, 500,
|
||||
`{"error":{"message":"the response discusses quota and balance concepts"}}`)
|
||||
if strings.Contains(out4, "额度已用尽") {
|
||||
t.Errorf("泛词 quota/balance 不得误伤(关键词必须窄),实得 %q", out4)
|
||||
}
|
||||
|
||||
// ⑤ 与通用 openai 适配器对比:证明**通用器确实做不到**这件事
|
||||
//(否则这个专用适配器就没有存在理由)
|
||||
gen, okGen, _ := vm.TransformError("openai", 403,
|
||||
`{"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}`)
|
||||
if okGen && strings.Contains(gen, "排队") {
|
||||
t.Error("通用 openai 适配器不该能识别排队 —— 若能,则专用适配器无必要")
|
||||
}
|
||||
}
|
||||
222
internal/core/channels_qoder_claim_test.go
Normal file
222
internal/core/channels_qoder_claim_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// Qoder 活动领取有三条参考用**抓包 + 用户报障**定位过的「会误报」陷阱,
|
||||
// 全部必须在判据里钉死:
|
||||
//
|
||||
// ① 「无可领活动」必须是 inactive,**不能**报 already-claimed
|
||||
// (用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」);
|
||||
// ② 刷新前(每日 10:00 UTC+8 之前)看到 CLAIMED 属于**昨天** → 必须报 inactive
|
||||
// 而不是"今天已领取"(谎报且不可逆);
|
||||
// ③ 未开通要给出 action_required 的可操作提示。
|
||||
//
|
||||
// 另:`replayed:true` 是幂等判据(HTTP 仍是 200),只看状态码会把"今天已领"
|
||||
// 误报成"成功 +100"。
|
||||
func TestQoderClaimDecisionTable(t *testing.T) {
|
||||
type c struct {
|
||||
name string
|
||||
campaigns string
|
||||
usage string
|
||||
wantKind string
|
||||
wantAction bool
|
||||
wantClaimed int
|
||||
}
|
||||
cases := []c{
|
||||
{
|
||||
"无可领且无 CLAIM_BENEFIT,addOnQuota 缺失→inactive+可操作提示",
|
||||
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"VIEW_DETAILS","claimStatus":"CLAIMABLE"}]}`,
|
||||
`{"data":{"userQuota":{"remaining":0}}}`,
|
||||
"inactive", true, 0,
|
||||
},
|
||||
{
|
||||
"无可领但确实领过(CLAIMED)→ 视刷新时刻",
|
||||
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMED"}]}`,
|
||||
`{"data":{"addOnQuota":{"remaining":0}}}`,
|
||||
"", false, 0, // 见下方:按当前 UTC+8 时刻断言二者之一
|
||||
},
|
||||
{
|
||||
"可领→success(多个活动逐个领)",
|
||||
`{"showCampaign":true,"claimable":true,"campaigns":[
|
||||
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}},
|
||||
{"campaignId":"C2","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":50}}]}`,
|
||||
"",
|
||||
"success", false, 2,
|
||||
},
|
||||
{
|
||||
"可领但服务端回放 replayed→already-claimed(不是 success)",
|
||||
`{"showCampaign":true,"claimable":true,"campaigns":[
|
||||
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`,
|
||||
"",
|
||||
"already-claimed", false, 1,
|
||||
},
|
||||
{
|
||||
"无任何活动且已开通(addOnQuota 存在)→inactive(不带可操作提示)",
|
||||
`{"showCampaign":false,"claimable":false,"campaigns":[]}`,
|
||||
`{"data":{"addOnQuota":{"total":100,"remaining":0}}}`,
|
||||
"inactive", false, 0,
|
||||
},
|
||||
{
|
||||
"有 CLAIM_BENEFIT 但已过期→inactive",
|
||||
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"EXPIRED"}]}`,
|
||||
`{"data":{"addOnQuota":{"remaining":0}}}`,
|
||||
"inactive", false, 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claimed := 0
|
||||
var claimBody string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/sash/api/v1/me/campaigns":
|
||||
fmt.Fprint(w, tc.campaigns)
|
||||
case r.URL.Path == "/sash/api/v2/me/usage":
|
||||
if tc.usage == "" {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, tc.usage)
|
||||
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
|
||||
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
|
||||
claimed++
|
||||
b := make([]byte, 0)
|
||||
if r.ContentLength > 0 {
|
||||
b = make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(b)
|
||||
}
|
||||
claimBody = string(b)
|
||||
if tc.name == "可领但服务端回放 replayed→already-claimed(不是 success)" {
|
||||
fmt.Fprint(w, `{"replayed":true,"status":"CLAIMED"}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "qoder",
|
||||
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"platform": "qoder", "credential": string(cred), "account": "t",
|
||||
})
|
||||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: string(body),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim_daily: %v", err)
|
||||
}
|
||||
j, _ := out.JSON.(map[string]interface{})
|
||||
oc, _ := j["outcome"].(map[string]interface{})
|
||||
if oc == nil {
|
||||
t.Fatalf("应返回 outcome,实得 %v", j)
|
||||
}
|
||||
kind, _ := oc["kind"].(string)
|
||||
|
||||
if tc.wantKind == "" {
|
||||
// 刷新时刻决定 already-claimed 还是 inactive —— 两者都合法,
|
||||
// 但**绝不能**是 success(今天是否领过不能靠"看到 CLAIMED"推断成可以再领)。
|
||||
if kind != "already-claimed" && kind != "inactive" {
|
||||
t.Fatalf("刷新前/后都应报 already-claimed 或 inactive,实得 %q(%v)", kind, oc["message"])
|
||||
}
|
||||
} else if kind != tc.wantKind {
|
||||
t.Fatalf("kind 应为 %s,实得 %q(%v)", tc.wantKind, kind, oc["message"])
|
||||
}
|
||||
if tc.wantAction && oc["action_required"] != true {
|
||||
t.Errorf("未开通场景应带 action_required=true(提示去官方客户端),实得 %v", oc["action_required"])
|
||||
}
|
||||
if claimed != tc.wantClaimed {
|
||||
t.Errorf("claim 请求次数 = %d,期望 %d", claimed, tc.wantClaimed)
|
||||
}
|
||||
if tc.wantClaimed > 0 && claimBody != "" {
|
||||
t.Errorf("claim 请求体必须是空串(抓包实测 content-length: 0),实得 %q", claimBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 「今天的活动列表是否已刷新」必须用 **UTC+8 算术平移**判定。
|
||||
// ⚠️ 本机时区恰是 HKT(+0800),所以「用本机时区判」的实现在真实时刻下**结果相同**
|
||||
// —— 那是判据不可达(这台机器上永远抓不到)。必须注入 UTC 时刻来覆盖两个方向:
|
||||
// 判据的输入是 UTC 毫秒,与运行环境时区无关。
|
||||
func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// UTC 01:00 = UTC+8 09:00 → **未刷新**(阈值 10:00)
|
||||
// UTC 02:00 = UTC+8 10:00 → **已刷新**
|
||||
// 若实现误用本机时区(HKT=UTC+8),对"UTC 毫秒"这个输入会得到:
|
||||
// 把 07:00Z 当本地 15:00 → 判已刷新(错)
|
||||
cases := []struct {
|
||||
name string
|
||||
utcHour int
|
||||
want bool
|
||||
}{
|
||||
{"UTC 01:00(UTC+8 09:00,未到 10:00)", 1, false},
|
||||
{"UTC 02:00(UTC+8 10:00,刚好刷新)", 2, true},
|
||||
{"UTC 00:30(UTC+8 08:30,未刷新)", 0, false},
|
||||
{"UTC 15:00(UTC+8 23:00,已刷新)", 15, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 构造该 UTC 时刻的毫秒值(固定日期,避免跨日干扰)
|
||||
// 用 time.Date 明确构造「00:00 UTC + N 小时」——手算 epoch 会引入
|
||||
// 时区/跨日误差(第一版就是这么错的:基准不是 UTC 零点)。
|
||||
ms := time.Date(2025, 10, 9, tc.utcHour, 0, 0, 0, time.UTC).UnixMilli()
|
||||
var rec map[string]interface{}
|
||||
got, err := ps.Call("channels", "__qoder_refresh_probe", map[string]interface{}{
|
||||
"now_ms": float64(ms),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
rec, _ = w["json"].(map[string]interface{})
|
||||
if rec == nil {
|
||||
t.Fatalf("无 json: %#v", got)
|
||||
}
|
||||
if rec["refreshed"] != tc.want {
|
||||
t.Fatalf("refreshed 应为 %v,实得 %v(UTC+8 阈值 10:00)", tc.want, rec["refreshed"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
internal/core/channels_qoder_machine_test.go
Normal file
84
internal/core/channels_qoder_machine_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Qoder 的设备身份两个头**必须成对**出现:参考用抓包 + 消融实验定位过 ——
|
||||
// 缺任一个,服务端就不下发「可领取」的活动(只回 VIEW_DETAILS),
|
||||
// 症状是用户报的「没领过却显示今日已领取」。
|
||||
// 同时:拿不到身份时必须**保守降级**(照常发请求、不带这两个头),
|
||||
// 不能让纯插件登录(未装桌面端)的账号整个积分功能报错。
|
||||
func TestQoderMachineIdentityPairingAndDegradation(t *testing.T) {
|
||||
t.Run("行1 有 machine_token.json → 成对带上两个头", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "machine_token.json")
|
||||
if err := os.WriteFile(p, []byte(`{"token":"T-abc","type":"TY-def","updateAt":1}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("QODER_MACHINE_TOKEN_PATH", p)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("无 json: %#v", got)
|
||||
}
|
||||
if m["token"] != "T-abc" || m["type"] != "TY-def" {
|
||||
t.Fatalf("应从磁盘缓存读出 token/type,实得 %v", m)
|
||||
}
|
||||
if m["client_type"] != "10" {
|
||||
t.Fatalf("Cosy-ClientType 应为 10(参考实测值),实得 %v", m["client_type"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 无任何来源 → 降级:不带这两个头,且不报错", func(t *testing.T) {
|
||||
// 指向不存在的路径,且把 HOME 指到空目录(找不到 runtime-info)
|
||||
t.Setenv("QODER_MACHINE_TOKEN_PATH", filepath.Join(t.TempDir(), "nope.json"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("APPDATA", "")
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("probe 不应报错(必须保守降级): %v", err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("无 json: %#v", got)
|
||||
}
|
||||
if m["token"] != nil && m["token"] != "" {
|
||||
t.Errorf("拿不到身份时不应带 token,实得 %v", m["token"])
|
||||
}
|
||||
if m["type"] != nil && m["type"] != "" {
|
||||
t.Errorf("拿不到身份时不应带 type,实得 %v", m["type"])
|
||||
}
|
||||
// 头仍然要有 clienttype(那是必要但不充分条件,与身份无关)
|
||||
if m["client_type"] != "10" {
|
||||
t.Errorf("降级时仍应带 Cosy-ClientType=10,实得 %v", m["client_type"])
|
||||
}
|
||||
})
|
||||
}
|
||||
181
internal/core/channels_qoder_test.go
Normal file
181
internal/core/channels_qoder_test.go
Normal file
@ -0,0 +1,181 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// qoderHarness:Qoder PKCE 设备码轮的测试台。
|
||||
//
|
||||
// 参考语义(qoder-oauth.ts / qoder.ts 注释点名):
|
||||
//
|
||||
// ① 授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
|
||||
// &nonce&machine_id&client_id —— **本地拼装,不打网络**
|
||||
// ② 轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
|
||||
// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
|
||||
//
|
||||
// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"——
|
||||
// 把聊天加密与登录混为一谈,导致该平台完全不可用。
|
||||
type qoderHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
poll int32 // 已发生的轮询次数
|
||||
// step: 第几次轮询开始返回 token(0 = 一直 404)
|
||||
tokenAt int32
|
||||
gotQuery string
|
||||
}
|
||||
|
||||
func newQoderHarness(t *testing.T) *qoderHarness {
|
||||
t.Helper()
|
||||
h := &qoderHarness{t: t, tokenAt: 2}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/api/v1/deviceToken/poll" {
|
||||
h.gotQuery = r.URL.RawQuery
|
||||
n := atomic.AddInt32(&h.poll, 1)
|
||||
if n < atomic.LoadInt32(&h.tokenAt) {
|
||||
w.WriteHeader(404) // 尚无会话
|
||||
fmt.Fprint(w, `{"code":404,"msg":"no session"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
// 把 poll 指到假上游(授权页是本地拼装,不经网络)
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "qoder",
|
||||
"device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *qoderHarness) begin() (map[string]interface{}, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
func (h *qoderHarness) pollOnce(sid string) map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。
|
||||
func TestQoderDevicePollBehavior(t *testing.T) {
|
||||
h := newQoderHarness(t)
|
||||
|
||||
t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) {
|
||||
j, _ := h.begin()
|
||||
url, _ := j["url"].(string)
|
||||
for _, want := range []string{
|
||||
"qoder.com/device/selectAccounts",
|
||||
"challenge=", "challenge_method=S256", "nonce=", "machine_id=",
|
||||
// client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒)
|
||||
"client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
|
||||
} {
|
||||
if !strings.Contains(url, want) {
|
||||
t.Fatalf("授权页缺 %q:\n%s", want, url)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 404视为继续_不误报失败", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.poll, 0)
|
||||
atomic.StoreInt32(&h.tokenAt, 99) // 一直 404
|
||||
_, sid := h.begin()
|
||||
for i := 0; i < 3; i++ {
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "pending" {
|
||||
t.Fatalf("404 应保持 pending, 实得 %v", st["state"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.poll, 0)
|
||||
atomic.StoreInt32(&h.tokenAt, 99)
|
||||
_, sid := h.begin()
|
||||
h.pollOnce(sid)
|
||||
q := h.gotQuery
|
||||
if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") {
|
||||
t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q)
|
||||
}
|
||||
if !strings.Contains(q, "challenge_method=S256") {
|
||||
t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.poll, 0)
|
||||
atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token
|
||||
_, sid := h.begin()
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("首拍应 pending: %v", st)
|
||||
}
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("拿到 token 后应 completed, 实得 %v", st)
|
||||
}
|
||||
// 凭据落保险库(旧路径键)
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:qoder")
|
||||
if !ok || !strings.Contains(v, "QD-AT-1") {
|
||||
t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.poll, 0)
|
||||
atomic.StoreInt32(&h.tokenAt, 1)
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin",
|
||||
})
|
||||
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("池路径应完成: %v", st)
|
||||
}
|
||||
if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok {
|
||||
t.Fatalf("池路径凭据未落到 channels:qoder:%s", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
165
internal/core/channels_raccoon_test.go
Normal file
165
internal/core/channels_raccoon_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。
|
||||
//
|
||||
// 参考语义(raccoon-oauth.ts 点名):
|
||||
//
|
||||
// ① qrcode_code **本地随机生成**(16 字节 hex),服务端接受任意自造 code
|
||||
// ② 轮询 POST /login_with_qrcode_code {qrcode_code}
|
||||
// → data.status ∈ pending / logging / canceled / success
|
||||
// ③ success 但**缺 token** 视为未完成(否则产出空凭据并卡死)
|
||||
// ④ **任何异常降级为 pending**(网络抖动/畸形响应/未知 status)
|
||||
type raccoonHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
polls int32
|
||||
step int32 // 第几次轮询开始 success
|
||||
gotCode string
|
||||
}
|
||||
|
||||
func newRaccoonHarness(t *testing.T) *raccoonHarness {
|
||||
t.Helper()
|
||||
h := &raccoonHarness{t: t, step: 3}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") {
|
||||
h.polls++
|
||||
if h.polls < atomic.LoadInt32(&h.step) {
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"logging"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"success","access_token":"RC-AT-1","refresh_token":"RC-RT-1"}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "raccoon",
|
||||
"qr": map[string]interface{}{
|
||||
"api_base": h.ts.URL,
|
||||
"auth_prefix": "/api/web/auth/v1",
|
||||
"poll_path": "/login_with_qrcode_code",
|
||||
"login_page": "/login/mp",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *raccoonHarness) begin() (map[string]interface{}, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"raccoon"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestRaccoonQrPollBehavior 逐行锁定参考的扫码登录语义。
|
||||
func TestRaccoonQrPollBehavior(t *testing.T) {
|
||||
h := newRaccoonHarness(t)
|
||||
|
||||
t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
j, _ := h.begin()
|
||||
qr, _ := j["qr_url"].(string)
|
||||
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") {
|
||||
t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr)
|
||||
}
|
||||
// code 应是 32 位 hex(本地随机生成)
|
||||
code := qr[strings.Index(qr, "code=")+5:]
|
||||
if i := strings.IndexAny(code, "&"); i > 0 {
|
||||
code = code[:i]
|
||||
}
|
||||
if len(code) != 32 {
|
||||
t.Fatalf("qrcode_code 应是 32 位 hex, 实得 %q", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 logging保持pending", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.step, 99)
|
||||
_, sid := h.begin()
|
||||
for i := 0; i < 3; i++ {
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 success且带token才完成", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.step, 2)
|
||||
_, sid := h.begin()
|
||||
h.pollOnce(sid)
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("success+token 应 completed, 实得 %v", st)
|
||||
}
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:raccoon")
|
||||
if !ok || !strings.Contains(v, "RC-AT-1") {
|
||||
t.Fatalf("凭据应含 RC-AT-1, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 canceled是终态失败", func(t *testing.T) {
|
||||
hh := newRaccoonHarness(t)
|
||||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"canceled"}}`)
|
||||
})
|
||||
hh.ps.SetSecret("channels", "channels:raccoon", "")
|
||||
_, sid := hh.begin()
|
||||
if st := hh.pollOnce(sid); st["state"] != "failed" {
|
||||
t.Fatalf("canceled 应 failed, 实得 %v", st["state"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
|
||||
hh := newRaccoonHarness(t)
|
||||
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"success"}}`)
|
||||
})
|
||||
_, sid := hh.begin()
|
||||
st := hh.pollOnce(sid)
|
||||
if st["state"] != "pending" {
|
||||
t.Fatalf("缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"])
|
||||
}
|
||||
})
|
||||
}
|
||||
193
internal/core/channels_refresh_test.go
Normal file
193
internal/core/channels_refresh_test.go
Normal file
@ -0,0 +1,193 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)。
|
||||
//
|
||||
// ⚠️ 修的是一个**比不做更坏**的缺陷:`account_refresh` 此前只重同步源、一个换
|
||||
// token 的请求都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」
|
||||
// 而实际什么都没续。日志在说谎,排查时会去看网络、看上游,而真因是根本没有
|
||||
// 这段代码。
|
||||
//
|
||||
// 判据必须**看到真请求** —— 否则就还是在验一个恒真的返回值。
|
||||
func TestAccountRefreshSendsRealRequest(t *testing.T) {
|
||||
var hits int32
|
||||
var gotBody, gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
gotPath = r.URL.Path
|
||||
b := make([]byte, 1024)
|
||||
n, _ := r.Body.Read(b)
|
||||
gotBody = string(b[:n])
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 照参考:Cline 响应套 {success,data} 信封,字段是**驼峰** accessToken
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{"accessToken":"NEW-TOKEN-CAMEL","refreshToken":"NEW-REFRESH"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
// 注入一个测试平台(夹具绝不写进生产源码)
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "rfptest", "refresh_url": srv.URL + "/api/v1/auth/refresh",
|
||||
"style": "cline", "credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("注入平台: %v", err)
|
||||
}
|
||||
|
||||
out, err := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||||
"platform": "rfptest", "account_id": "a1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_refresh: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
|
||||
// ① 必须真的发出了请求(这是本判据的核心 —— 原实现 hits=0)
|
||||
if n := atomic.LoadInt32(&hits); n != 1 {
|
||||
t.Fatalf("必须真发一次续期请求,实得 %d 次(原实现是 0 次却报成功)", n)
|
||||
}
|
||||
if !strings.Contains(gotPath, "/api/v1/auth/refresh") {
|
||||
t.Errorf("请求路径不对: %s", gotPath)
|
||||
}
|
||||
// ② 请求体必须是**驼峰** refreshToken/grantType(照参考 cline.ts)
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
|
||||
t.Fatalf("请求体不是 JSON(Cline 要 JSON body): %q", gotBody)
|
||||
}
|
||||
if _, ok := sent["refreshToken"]; !ok {
|
||||
t.Errorf("请求体必须是驼峰 refreshToken(不是 refresh_token),实得 %v", sent)
|
||||
}
|
||||
if sent["grantType"] != "refresh_token" {
|
||||
t.Errorf("grantType 应为 refresh_token,实得 %v", sent["grantType"])
|
||||
}
|
||||
|
||||
// ③ 必须报 refreshed=true,且**新 token 真的落进凭据**
|
||||
if j["refreshed"] != true {
|
||||
t.Errorf("应报 refreshed=true,实得 %v(message=%v)", j["refreshed"], j["message"])
|
||||
}
|
||||
if j["ok"] != true {
|
||||
t.Errorf("应报 ok=true,实得 %v", j["ok"])
|
||||
}
|
||||
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||||
"ref": "channels:rfptest",
|
||||
})
|
||||
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
|
||||
cred, _ := rj["credential"].(string)
|
||||
if !strings.Contains(cred, "NEW-TOKEN-CAMEL") {
|
||||
t.Errorf("新 token 必须写回凭据,实得 %q", cred)
|
||||
}
|
||||
// ④ 轮换的 refresh_token 也要回写(Google 场景;Cline 也返回了新的)
|
||||
if !strings.Contains(cred, "NEW-REFRESH") {
|
||||
t.Errorf("轮换的 refresh_token 必须回写,实得 %q", cred)
|
||||
}
|
||||
}
|
||||
|
||||
// 失败时**必须如实回错误**,不能像原实现那样恒 ok:true。
|
||||
func TestAccountRefreshReportsFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(401)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"refresh token expired"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "rfpfail", "refresh_url": srv.URL + "/refresh", "style": "google",
|
||||
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
out, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||||
"platform": "rfpfail", "account_id": "a1",
|
||||
})
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
// ⚠️ 原实现在这里恒 ok:true/refreshed 隐含成功 —— 这就是日志说谎的机制
|
||||
if j["refreshed"] == true {
|
||||
t.Error("续期失败时绝不能报 refreshed=true")
|
||||
}
|
||||
msg, _ := j["message"].(string)
|
||||
if !strings.Contains(msg, "401") {
|
||||
t.Errorf("失败原因应含 HTTP 状态(可诊断),实得 %q", msg)
|
||||
}
|
||||
|
||||
// 无 refresh_token 时必须明确说"需重新登录",而不是笼统的失败
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "rfpnort", "refresh_url": srv.URL + "/refresh", "style": "google",
|
||||
"credential": `{"access_token":"OLD"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
out2, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||||
"platform": "rfpnort", "account_id": "a1",
|
||||
})
|
||||
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
m2, _ := j2["message"].(string)
|
||||
if !strings.Contains(m2, "重新登录") {
|
||||
t.Errorf("无 refresh_token 时应提示需重新登录,实得 %q", m2)
|
||||
}
|
||||
}
|
||||
|
||||
// expires_in 是**相对秒数**,必须换算成绝对时刻 —— 否则每轮都判定"快过期"再续一遍。
|
||||
func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"NEW","refresh_token":"R2","expires_in":7200}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "rfpexp", "refresh_url": srv.URL + "/token", "style": "google",
|
||||
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("注入: %v", err)
|
||||
}
|
||||
if _, err := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||||
"platform": "rfpexp", "account_id": "a1",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_refresh: %v", err)
|
||||
}
|
||||
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||||
"ref": "channels:rfpexp",
|
||||
})
|
||||
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
|
||||
cred, _ := rj["credential"].(string)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(cred), &m)
|
||||
ea, _ := m["expires_at"].(string)
|
||||
if ea == "" {
|
||||
t.Fatal("expires_in 必须换算成绝对 expires_at 写回(否则每轮都重续)")
|
||||
}
|
||||
// 必须是**绝对**时刻(10 位秒级),不是相对秒数 7200
|
||||
if ea == "7200" {
|
||||
t.Error("expires_at 写成了相对秒数 —— 必须换算成绝对时刻")
|
||||
}
|
||||
if len(ea) < 9 {
|
||||
t.Errorf("expires_at 看着不像绝对时刻: %q", ea)
|
||||
}
|
||||
// 索引的 expiresAt 也必须回写(照 expiry-sync:否则出现"显示已过期但能发消息"分叉)。
|
||||
// ⚠️ 必须通过**账号列表**端点读索引 —— __read_cred_probe 只读保险库,
|
||||
// 用它验索引会永远看到零,把"没验到"误判成"没写"。
|
||||
al, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "rfpexp"})
|
||||
aj, _ := al.(map[string]interface{})["json"].(map[string]interface{})
|
||||
accounts := asArrInterface(aj["accounts"])
|
||||
if len(accounts) == 0 {
|
||||
t.Fatal("账号列表为空")
|
||||
}
|
||||
first, _ := accounts[0].(map[string]interface{})
|
||||
idx, _ := first["expiresAt"].(float64)
|
||||
if idx == 0 {
|
||||
t.Error("索引的 expiresAt 必须一并回写(expiry-sync 的分叉缺陷)")
|
||||
}
|
||||
// 且应当是**绝对**秒级时刻(不是相对 7200)
|
||||
if idx > 0 && idx < 1e9 {
|
||||
t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx)
|
||||
}
|
||||
}
|
||||
125
internal/core/channels_store_atomic_test.go
Normal file
125
internal/core/channels_store_atomic_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 账号池落盘的**原子性与一致性**(照参考 account-pool.ts 的真实缺陷)。
|
||||
//
|
||||
// 参考的缺陷:`removeAccount` 只保证「读→改」之间无 await,而内部的
|
||||
// `await store.save(...)` 是**异步**的 ⇒ 两个写者各自的 save 可以**乱序完成**,
|
||||
// 后完成者带着它更早的快照覆盖磁盘。实测形态:
|
||||
// - 并发删 a / 删 b(先发起的后完成)⇒ **已删除的账号在磁盘上复活**;
|
||||
// - 删 victim 与加 added 交错 ⇒ **新增账号被静默丢失**。
|
||||
//
|
||||
// 我们的实现是**同步** tmp+rename(Lua 无异步),且每次写都序列化**当前**
|
||||
// `plugin.state`(不是调用方早先取的快照)⇒ 该竞态结构上不可能发生。
|
||||
// 但"不可能"必须被验证,否则只是一句自信。
|
||||
func TestAccountPoolStoreIsAtomicAndConsistent(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
// 找出插件的存储目录(内核给的 ground truth,不是猜的)
|
||||
dirOut, err := ps.Call("channels", "__store_dir_probe", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("store_dir_probe: %v", err)
|
||||
}
|
||||
dj, _ := dirOut.(map[string]interface{})["json"].(map[string]interface{})
|
||||
dir, _ := dj["store_dir"].(string)
|
||||
if dir == "" {
|
||||
t.Fatalf("拿不到插件存储目录,实得 %#v", dirOut)
|
||||
}
|
||||
storePath := filepath.Join(dir, ".channels-store.json")
|
||||
t.Logf("插件存储目录: %s", dir)
|
||||
// ⚠️ 必须在**测试目录**里,绝不能是生产路径
|
||||
if _, err := os.Stat("/etc/llmsproxy/plugins/.channels-store.json"); err == nil {
|
||||
if filepath.Clean(dir) == "/etc/llmsproxy/plugins" {
|
||||
t.Fatal("测试正在使用生产存储目录 —— 隔离失效(这会造成真实数据事故)")
|
||||
}
|
||||
}
|
||||
|
||||
// 连续做一组"读-改-写"(模拟并发操作最终落在同一份文档上)
|
||||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||||
"id": "atomic1", "style": "google", "credential": `{"access_token":"a"}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"a", "b", "c"} {
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "atomic1", "account_id": id}); err != nil {
|
||||
t.Fatalf("add %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// 删 a、删 b(参考的缺陷场景)
|
||||
for _, id := range []string{"a", "b"} {
|
||||
if _, err := ps.Call("channels", "account_delete", map[string]interface{}{
|
||||
"platform": "atomic1", "account_id": id}); err != nil {
|
||||
t.Fatalf("delete %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// 再加一个(参考的第二个缺陷场景:与删除交错的写)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "atomic1", "account_id": "d"}); err != nil {
|
||||
t.Fatalf("add d: %v", err)
|
||||
}
|
||||
|
||||
// 内存视图
|
||||
mem, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "atomic1"})
|
||||
mj, _ := mem.(map[string]interface{})["json"].(map[string]interface{})
|
||||
memIDs := map[string]bool{}
|
||||
for _, a := range asArrInterface(mj["accounts"]) {
|
||||
if am, ok := a.(map[string]interface{}); ok {
|
||||
if id, ok := am["id"].(string); ok {
|
||||
memIDs[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 磁盘视图:必须是**合法 JSON**(tmp+rename 的原子性保证:绝无半截文件)
|
||||
b, err := os.ReadFile(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("读存储: %v", err)
|
||||
}
|
||||
var disk map[string]interface{}
|
||||
if err := json.Unmarshal(b, &disk); err != nil {
|
||||
t.Fatalf("磁盘文档不是合法 JSON(原子替换失效,出现半截文件): %v", err)
|
||||
}
|
||||
// 文档形状:{version, state:{pool, accounts, ...}} —— pool 在 state 之下。
|
||||
st, _ := disk["state"].(map[string]interface{})
|
||||
if st == nil {
|
||||
t.Fatalf("磁盘文档缺 state 段,实得 %v", disk)
|
||||
}
|
||||
pool, _ := st["pool"].(map[string]interface{})
|
||||
diskIDs := map[string]bool{}
|
||||
if pl, ok := pool["atomic1"].(map[string]interface{}); ok {
|
||||
for id := range pl {
|
||||
diskIDs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ① 已删除的账号**不得**在磁盘上复活(参考的第一个缺陷形态)
|
||||
for _, gone := range []string{"a", "b"} {
|
||||
if diskIDs[gone] {
|
||||
t.Errorf("已删除的账号 %q 在磁盘上复活了(参考的并发写竞态形态)", gone)
|
||||
}
|
||||
if memIDs[gone] {
|
||||
t.Errorf("已删除的账号 %q 仍在内存视图里", gone)
|
||||
}
|
||||
}
|
||||
// ② 新增的账号**不得**被静默丢失(参考的第二个缺陷形态)
|
||||
if !diskIDs["d"] {
|
||||
t.Error("新增账号 d 在磁盘上缺失(被静默丢失 —— 参考的竞态形态)")
|
||||
}
|
||||
// ③ 内存与磁盘必须一致(分叉本身就是缺陷)
|
||||
for id := range memIDs {
|
||||
if !diskIDs[id] {
|
||||
t.Errorf("账号 %q 在内存里有、磁盘上没有(内存/磁盘分叉)", id)
|
||||
}
|
||||
}
|
||||
for id := range diskIDs {
|
||||
if !memIDs[id] {
|
||||
t.Errorf("账号 %q 在磁盘上有、内存里没有(内存/磁盘分叉)", id)
|
||||
}
|
||||
}
|
||||
_ = c
|
||||
}
|
||||
222
internal/core/channels_store_test.go
Normal file
222
internal/core/channels_store_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
|
||||
//
|
||||
// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4
|
||||
// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误**
|
||||
// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现
|
||||
// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化
|
||||
// 为纯内存,正解是插件自持文档。
|
||||
//
|
||||
// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。
|
||||
func TestChannelsStoreIsPluginOwned(t *testing.T) {
|
||||
// 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
|
||||
// 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
|
||||
load := func() *lua.Plugins {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "list_sources", "remove_source", "status"}, false)
|
||||
return ps
|
||||
}
|
||||
|
||||
ps := load()
|
||||
// 加账号 → 状态变更 → 应立即落盘
|
||||
add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
|
||||
// ① 插件自有存储文件必须存在
|
||||
storePath := filepath.Join(dir, ".channels-store.json")
|
||||
b, err := os.ReadFile(storePath)
|
||||
if err != nil {
|
||||
t.Fatalf("插件自有存储未写出(%s): %v\n"+
|
||||
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
|
||||
}
|
||||
var saved struct {
|
||||
State map[string]interface{} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &saved); err != nil {
|
||||
t.Fatalf("存储文件不是合法 JSON: %v", err)
|
||||
}
|
||||
pool, _ := saved.State["pool"].(map[string]interface{})
|
||||
buddy, _ := pool["buddy"].(map[string]interface{})
|
||||
if _, ok := buddy[aid]; !ok {
|
||||
t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool)
|
||||
}
|
||||
|
||||
// ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600)
|
||||
if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 {
|
||||
t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)")
|
||||
}
|
||||
raw := string(b)
|
||||
for _, bad := range []string{"access_token", "refresh_token", "api_key"} {
|
||||
if strings.Contains(raw, bad) {
|
||||
t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 新实例(模拟重启)必须读回账号
|
||||
ps2 := load()
|
||||
lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("account_list: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||||
if raw.(map[string]interface{})["id"] == aid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("重启后账号 %s 未恢复 —— 持久化未生效", aid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件
|
||||
// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。
|
||||
//
|
||||
// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 ——
|
||||
// 写失败还删它,等于用一次迁移把数据弄丢。
|
||||
func TestChannelsMigratesLegacyKernelState(t *testing.T) {
|
||||
// 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离)
|
||||
dir := ""
|
||||
|
||||
// 造一个内核遗留文件(v2.0.4 的形状)
|
||||
legacy := map[string]interface{}{
|
||||
"version": 1,
|
||||
"state": map[string]interface{}{
|
||||
"accounts": map[string]interface{}{},
|
||||
"log": []interface{}{},
|
||||
"pool": map[string]interface{}{
|
||||
"buddy": map[string]interface{}{
|
||||
"legacy-acct": map[string]interface{}{
|
||||
"id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号",
|
||||
"enabled": true, "credentialRef": "channels:buddy:legacy-acct",
|
||||
"createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(legacy)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
dir = c.Config().PluginDir
|
||||
legacyPath := filepath.Join(dir, ".channels.state.json")
|
||||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 遗留账号必须被迁移进内存(可见)
|
||||
lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
|
||||
if raw.(map[string]interface{})["id"] == "legacy-acct" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("遗留账号未迁移 —— 升级即丢账号")
|
||||
}
|
||||
|
||||
// ② 自有存储必须已写出
|
||||
if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil {
|
||||
t.Fatalf("迁移后自有存储应存在: %v", err)
|
||||
}
|
||||
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatal("遗留内核文件应被删除,否则下次启动会被它覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约:
|
||||
// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把
|
||||
// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。
|
||||
//
|
||||
// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在
|
||||
// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。
|
||||
func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
|
||||
// 强制写失败的手段:把 persistState 要写的临时文件路径
|
||||
// (<dir>/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
|
||||
// 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
|
||||
sub := t.TempDir()
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
|
||||
|
||||
legacy := map[string]interface{}{
|
||||
"version": 1,
|
||||
"state": map[string]interface{}{
|
||||
"pool": map[string]interface{}{
|
||||
"buddy": map[string]interface{}{
|
||||
"keepme": map[string]interface{}{
|
||||
"id": "keepme", "provider": "buddy",
|
||||
"credentialRef": "channels:buddy:keepme", "createdAt": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(legacy)
|
||||
legacyPath := filepath.Join(sub, ".channels.state.json")
|
||||
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.LoadSource("channels", src)
|
||||
|
||||
// 写失败的态下,遗留文件必须原样保留
|
||||
if _, err := os.Stat(legacyPath); err != nil {
|
||||
t.Fatalf("写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
|
||||
}
|
||||
// 且自有存储不应存在(写失败了)
|
||||
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {
|
||||
t.Fatal("tmp 路径被占住时不应写出自有存储")
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,18 @@ func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) {
|
||||
return c, ps
|
||||
}
|
||||
|
||||
// cookieishPlatformFixture 是压力测试用的占位平台(对应此前内联在生产源码里的
|
||||
// "cookieish")。夹具经运行时注入,生产目录只含真实厂商。
|
||||
const cookieishPlatformFixture = `[{
|
||||
"id": "cookieish",
|
||||
"name": "Cookie 平台(测试夹具)",
|
||||
"flow": "paste",
|
||||
"base_url": "https://api.cookieish.example/v1",
|
||||
"adapter": "openai",
|
||||
"models": ["c-model-1"],
|
||||
"checkin": {"url": "https://api.cookieish.example/v1/api/credit/claim", "method": "POST", "body": "{}"}
|
||||
}]`
|
||||
|
||||
// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。
|
||||
//
|
||||
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
|
||||
@ -134,6 +146,7 @@ func TestChannelsClaimStress(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := channelsCore(t, mustChannelsSrc(t))
|
||||
injectTestPlatforms(t, ps, cookieishPlatformFixture)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "cookieish",
|
||||
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},
|
||||
@ -191,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)
|
||||
@ -239,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)
|
||||
|
||||
111
internal/core/channels_trae_checkin_test.go
Normal file
111
internal/core/channels_trae_checkin_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// Trae 签到是最容易被"看起来对"骗过的地方:请求能发出去、上游可能也返回 200,
|
||||
// 但头不对就只是一个失败/风控响应。参考实现明确记录:
|
||||
//
|
||||
// ① 授权方案是 Cloud-IDE-JWT,**不是** Bearer(用错前缀是 401 级失败);
|
||||
// ② 简化头(只有 Authorization + 几个 Ug 头)**实测签到失败**,
|
||||
// 真正成功的是约 20 个客户端头;
|
||||
// ③ X-Device-Id 不是凭据里的 hex32,而是**基于 user_id 确定性派生的 15 位数字**
|
||||
// (每账号天然不同设备,所以不再需要 9074 时换设备号重试)。
|
||||
//
|
||||
// 本测试按参考逐条锁定这些事实,并把派生的 device_id 钉死到与参考一致的值。
|
||||
func TestTraeCheckinHeaders(t *testing.T) {
|
||||
var got map[string]string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = map[string]string{}
|
||||
for k := range r.Header {
|
||||
got[k] = r.Header.Get(k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把签到端点指到假上游(不覆盖 checkin.auth,验证声明真的在起作用)
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"checkin": map[string]interface{}{"url": ts.URL},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// uid 必须是真值:设备身份由它派生。用参考实现算出的期望值(见 tools/trae_derive_expected.mjs)
|
||||
const uid = "12345"
|
||||
// credential 传**字符串**(凭据是一段 JSON 文本),不是对象 —— complete_login
|
||||
// 会原样存进保险库,取用时 credentialOf 再解出 access_token。
|
||||
cred, _ := json.Marshal(map[string]interface{}{
|
||||
"access_token": "ACCESS-1", "uid": uid, "device_id": "deadbeef",
|
||||
})
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"platform": "trae", "credential": string(cred), "account": "t",
|
||||
})
|
||||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: string(body),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("claim_daily: %v", err)
|
||||
}
|
||||
if j, _ := out.JSON.(map[string]interface{}); j != nil && j["error"] != nil {
|
||||
t.Fatalf("claim 失败: %v(完整响应 %v)", j["error"], j)
|
||||
}
|
||||
|
||||
t.Run("行1 授权方案是 Cloud-IDE-JWT", func(t *testing.T) {
|
||||
if v := got["Authorization"]; v != "Cloud-IDE-JWT ACCESS-1" {
|
||||
t.Fatalf("Authorization 必须是 Cloud-IDE-JWT <token>(Bearer 会 401),实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行2 设备身份是按 user_id 派生的 15 位数字", func(t *testing.T) {
|
||||
// 期望值来自参考实现(Node + node:crypto)的派生,不是本实现自己算的
|
||||
if v := got["X-Device-Id"]; v != "707304999019651" {
|
||||
t.Fatalf("X-Device-Id 应为按 uid 派生的 15 位数字 707304999019651,实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行3 客户端身份头齐全", func(t *testing.T) {
|
||||
for k, want := range map[string]string{
|
||||
"X-Market-Client-Id": "VSCode 1.107.1",
|
||||
"X-User-Region": "CN",
|
||||
"Package-Type": "stable_cn",
|
||||
"X-Lgw-Req-Sdk-Type": "3",
|
||||
"X-Lscbd-Platform": "windows",
|
||||
"App-Version": "0.1.52",
|
||||
} {
|
||||
if v := got[k]; v != want {
|
||||
t.Errorf("头 %s = %q,期望 %q", k, v, want)
|
||||
}
|
||||
}
|
||||
if got["X-Market-User-Id"] == "" || len(got["X-Market-User-Id"]) != 36 {
|
||||
t.Errorf("X-Market-User-Id 应是 UUID v4 形状,实得 %q", got["X-Market-User-Id"])
|
||||
}
|
||||
if len(got["Vscode-Sessionid"]) != 64 {
|
||||
t.Errorf("Vscode-Sessionid 应是 64 位 hex,实得 %q", got["Vscode-Sessionid"])
|
||||
}
|
||||
if got["X-Tt-Trace-Id"] == "" || got["X-Request-Id"] == "" {
|
||||
t.Error("每次请求都应有独立的 X-Tt-Trace-Id / X-Request-Id")
|
||||
}
|
||||
})
|
||||
}
|
||||
63
internal/core/channels_trae_derive_test.go
Normal file
63
internal/core/channels_trae_derive_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 手工移植的确定性派生必须与参考**逐字节一致**,否则签名的设备身份是错的
|
||||
// 且不会报错(上游只会返回风控/失败)。所以拿参考实现(Node + node:crypto)
|
||||
// 生成期望值,再让插件的 Lua 实现算同一组输入做差分。
|
||||
//
|
||||
// 期望值由 tools/trae_derive_expected.json 提供(用参考 seededStream 生成)。
|
||||
func TestTraeDeriveMatchesReference(t *testing.T) {
|
||||
raw, err := os.ReadFile("trae_derive_expected.json")
|
||||
if err != nil {
|
||||
t.Skipf("缺差分数据(跳过): %v", err)
|
||||
}
|
||||
var cases []struct {
|
||||
U string `json:"u"`
|
||||
Devid string `json:"devid"`
|
||||
Market string `json:"market"`
|
||||
Sess string `json:"sess"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got, err := ps.Call("channels", "__trae_derive_probe", map[string]interface{}{"uid": tc.U})
|
||||
if err != nil {
|
||||
t.Fatalf("probe %q: %v", tc.U, err)
|
||||
}
|
||||
wrapped, _ := got.(map[string]interface{})
|
||||
m, _ := wrapped["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("probe %q 无 json: %#v", tc.U, got)
|
||||
}
|
||||
if fmt.Sprint(m["devid"]) != tc.Devid {
|
||||
t.Errorf("devid(%q) = %v,参考 %s", tc.U, m["devid"], tc.Devid)
|
||||
}
|
||||
if fmt.Sprint(m["market"]) != tc.Market {
|
||||
t.Errorf("market(%q) = %v,参考 %s", tc.U, m["market"], tc.Market)
|
||||
}
|
||||
if fmt.Sprint(m["sess"]) != tc.Sess {
|
||||
t.Errorf("sess(%q) = %v,参考 %s", tc.U, m["sess"], tc.Sess)
|
||||
}
|
||||
}
|
||||
_ = lua.HandlerRequest{}
|
||||
}
|
||||
169
internal/core/channels_trae_pkce_test.go
Normal file
169
internal/core/channels_trae_pkce_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
|
||||
// 而不是去调 ExchangeToken 换一个不存在的码。
|
||||
//
|
||||
// 真实缺陷(用户报「登录后显示失败」):
|
||||
// poll_login 的 trae 分支曾自己发明了一段「授权码交换」——把 {code} 塞进
|
||||
// exchange_body 再打 ExchangeToken。但:
|
||||
//
|
||||
// · ExchangeToken 的签名是 RefreshToken,不是 code;
|
||||
// · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
|
||||
// · client_id 当时是空串。
|
||||
//
|
||||
// 于是稳定得到 400,日志回一句误导性的「Trae 授权码交换失败: 400」,
|
||||
// 把真因「上游走了 PKCE 流程」掩盖成「交换出错了」——排查方向完全错。
|
||||
//
|
||||
// 参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法是:带 code 的回调
|
||||
// 是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
|
||||
func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。
|
||||
// oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就
|
||||
// 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。
|
||||
//
|
||||
// poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state},
|
||||
// 故判据直接摆好那个文件再调 poll_login。
|
||||
status, body := h.beginAndPollWithCallback(map[string]string{
|
||||
"code": "PKCE_AUTH_CODE_XYZ",
|
||||
})
|
||||
|
||||
// poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。
|
||||
// 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把
|
||||
// 前者写成了后者,于是把正确的实现判成错。
|
||||
if !strings.Contains(body, "state:failed") {
|
||||
t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s",
|
||||
status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
|
||||
t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+
|
||||
"实际打了 %d 次", n)
|
||||
}
|
||||
// 错误信息必须指向真因(PKCE),而不是含糊的交换失败
|
||||
blob := body
|
||||
if !strings.Contains(blob, "PKCE") {
|
||||
t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob)
|
||||
}
|
||||
if strings.Contains(blob, "授权码交换失败") {
|
||||
t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob)
|
||||
}
|
||||
t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob))
|
||||
}
|
||||
|
||||
// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。
|
||||
// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。
|
||||
func TestTraeExchangeSendsRealClientID(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。
|
||||
// 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。
|
||||
status, body := h.beginAndCallback(map[string]string{
|
||||
"refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n == 0 {
|
||||
t.Fatal("token 直传场景必须真的调用 ExchangeToken")
|
||||
}
|
||||
t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit))
|
||||
}
|
||||
|
||||
// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。
|
||||
func TestTraeClientIDIsNotBlank(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) {
|
||||
t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " +
|
||||
"留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400")
|
||||
}
|
||||
}
|
||||
|
||||
// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论,
|
||||
// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。
|
||||
func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
// ① 不得再出现自己发明的交换(注释里说明历史的那句除外)
|
||||
if strings.Contains(src, `cerr = "Trae 授权码交换失败`) {
|
||||
t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," +
|
||||
"必然 400 且把真因掩盖成交换失败")
|
||||
}
|
||||
// ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定)
|
||||
if n := strings.Count(src, "traeCallback(plat,"); n < 2 {
|
||||
t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n)
|
||||
}
|
||||
// ③ PKCE 判定必须仍在 traeCallback 内
|
||||
if !strings.Contains(src, "本实现暂不支持该流程") {
|
||||
t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)")
|
||||
}
|
||||
}
|
||||
|
||||
// beginAndPollWithCallback 走**真实登录用的那条路径**:
|
||||
// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。
|
||||
//
|
||||
// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是
|
||||
// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的
|
||||
// trae 分支里。
|
||||
func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) {
|
||||
h.t.Helper()
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j, ok := out.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
|
||||
}
|
||||
sess := j["session"].(map[string]interface{})
|
||||
sid, _ := sess["id"].(string)
|
||||
nonce, _ := sess["nonce"].(string)
|
||||
if sid == "" {
|
||||
h.t.Fatalf("会话缺 id: %#v", sess)
|
||||
}
|
||||
|
||||
// 摆好 loopback 回调文件(内容 = 平台重定向带回的 query)
|
||||
rec := map[string]string{"state": nonce}
|
||||
for k, v := range cbQuery {
|
||||
rec[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(rec)
|
||||
cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json")
|
||||
if err := os.WriteFile(cbPath, b, 0600); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
// 告诉插件「这个会话的 loopback 接收器把结果写在这里了」
|
||||
if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{
|
||||
"session_id": sid, "file": cbPath,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("设置 loopback 文件: %v", err)
|
||||
}
|
||||
|
||||
res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid},
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
@ -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)")
|
||||
@ -171,3 +192,67 @@ func TestTraeCallbackBehavior(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTraeCallbackPoolPath 回归:callback 流的**池路径**(UI 真实流程)。
|
||||
//
|
||||
// 与 buddy poll_login 的池路径同一缺陷:complete_login 不带 account_id 时,
|
||||
// 凭据落到与占位账号无关的 `channels:<plat>` 键,池账号永远 has_credential=false。
|
||||
// 此前只有 device-poll / state-poll 分支修了,callback 分支是漏网。
|
||||
func TestTraeCallbackPoolPath(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
atomic.StoreInt32(&h.exchOK, 1)
|
||||
|
||||
// UI 流程:先建占位账号,再带 account_id 发起登录
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"trae","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
|
||||
|
||||
st, body := h.invokeCallback(map[string]string{
|
||||
"state": nonce, "refreshToken": "RT-FROM-CALLBACK", "userInfo": `{"UserID":"u9","ScreenName":"池用户"}`,
|
||||
})
|
||||
if st != 200 {
|
||||
t.Fatalf("回调应 200: %d %s", st, body)
|
||||
}
|
||||
|
||||
// ① 凭据必须落在池账号的 ref
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true
|
||||
lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"trae"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range lst.JSON.(map[string]interface{})["accounts"].([]interface{}) {
|
||||
a := raw.(map[string]interface{})
|
||||
if a["id"] == aid && a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invokeCallback 直打 oauth_callback(Query 已含 state)。
|
||||
func (h *traeHarness) invokeCallback(q map[string]string) (int, string) {
|
||||
res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{
|
||||
Method: "GET", Query: q,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
|
||||
100
internal/core/channels_ttl_cache_test.go
Normal file
100
internal/core/channels_ttl_cache_test.go
Normal file
@ -0,0 +1,100 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 通用件:TTL 缓存 + 串行锁(照参考 ttl-cache.ts / serial-queue.ts)。
|
||||
//
|
||||
// 抽它们出来的理由:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"
|
||||
// 逻辑(远端目录闸门、余额 60s 缓存、登录轮询…)。手写版本各写各的,
|
||||
// 边界条件容易漏 —— 实测就漏了一个(见下面 ③)。
|
||||
func TestTtlCacheSemantics(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "__ttl_reset_probe", nil); err != nil {
|
||||
t.Fatalf("重置: %v", err)
|
||||
}
|
||||
|
||||
// ① 首次加载后命中缓存:loader 只该被调一次
|
||||
out, err := ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 60,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("探针: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if j["value"] != "v1" {
|
||||
t.Errorf("首次应加载出 v1,实得 %v", j["value"])
|
||||
}
|
||||
if n, _ := j["loads"].(float64); n != 1 {
|
||||
t.Errorf("首次后 loads 应为 1,实得 %v", n)
|
||||
}
|
||||
// 再取一次:命中缓存,loads 不变
|
||||
out2, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 60,
|
||||
})
|
||||
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if n, _ := j2["loads"].(float64); n != 1 {
|
||||
t.Errorf("命中缓存时不该再加载,loads 实得 %v", n)
|
||||
}
|
||||
|
||||
// ② 失效后必须能重新加载(包括同一秒内 —— 见 ③)
|
||||
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k",
|
||||
}); err != nil {
|
||||
t.Fatalf("失效: %v", err)
|
||||
}
|
||||
out3, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 60,
|
||||
})
|
||||
j3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
|
||||
// ⚠️ 这是实测踩到的坑:只清缓存、不清"在飞标记"时,同一秒内的重载会被
|
||||
// 去重逻辑挡住,返回 nil —— 表现为"刚清了缓存却拿不到值"。
|
||||
if j3["value"] == nil {
|
||||
t.Fatalf("失效后立刻重载必须拿到值(清缓存时也要清在飞标记),实得 nil")
|
||||
}
|
||||
if n, _ := j3["loads"].(float64); n != 2 {
|
||||
t.Errorf("失效后应重新加载(loads=2),实得 %v", n)
|
||||
}
|
||||
|
||||
// ③ TTL=0 视为立即过期:不该命中
|
||||
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k",
|
||||
}); err != nil {
|
||||
t.Fatalf("失效: %v", err)
|
||||
}
|
||||
_, _ = ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 0,
|
||||
})
|
||||
out5, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
|
||||
"ns": "t", "key": "k", "ttl": 0,
|
||||
})
|
||||
j5, _ := out5.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if n, _ := j5["loads"].(float64); n < 3 {
|
||||
t.Errorf("ttl=0 不该命中缓存(每次都要重载),loads 实得 %v", n)
|
||||
}
|
||||
|
||||
// ④ 串行锁:抢到 → 第二次抢不到 → 释放后能再抢
|
||||
a, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
|
||||
"ns": "s", "ttl": 60})
|
||||
ja, _ := a.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if ja["acquired"] != true {
|
||||
t.Errorf("首次应抢到,实得 %v", ja["acquired"])
|
||||
}
|
||||
b, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
|
||||
"ns": "s", "ttl": 60})
|
||||
jb, _ := b.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if jb["acquired"] != false {
|
||||
t.Errorf("持有期间第二次不该抢到(这就是串行的意义),实得 %v", jb["acquired"])
|
||||
}
|
||||
if _, err := ps.Call("channels", "__serial_release_probe", map[string]interface{}{
|
||||
"ns": "s"}); err != nil {
|
||||
t.Fatalf("释放: %v", err)
|
||||
}
|
||||
c, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
|
||||
"ns": "s", "ttl": 60})
|
||||
jc, _ := c.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if jc["acquired"] != true {
|
||||
t.Errorf("释放后应能再抢到,实得 %v", jc["acquired"])
|
||||
}
|
||||
}
|
||||
59
internal/core/channels_vnc_gpu_test.go
Normal file
59
internal/core/channels_vnc_gpu_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// VNC 一直白屏的真因(2026-10-14 定位)。
|
||||
//
|
||||
// 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium
|
||||
// **独立 GPU 进程**启动所需的系统调用 ⇒ 它起不来 ⇒ chromium 放弃并退出:
|
||||
//
|
||||
// ERROR:gpu_process_host.cc] GPU process launch failed: error_code=1002
|
||||
// FATAL:gpu_data_manager_impl_private.cc] GPU process isn't usable. Goodbye.
|
||||
//
|
||||
// 整个 chromium 几秒内退出 ⇒ **VNC 上永远白屏**,而插件因为只等 CDP 端口就报
|
||||
// 「已就绪」,把白屏谎报成成功。
|
||||
//
|
||||
// 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功。定位靠
|
||||
// `systemd-run` 逐 directive 二分 —— 只有 SystemCallFilter 与 ProtectSystem
|
||||
// 能复现,其余加固项无影响。
|
||||
//
|
||||
// 修法:`--in-process-gpu`(GPU 逻辑跑在浏览器进程内,不 fork)。
|
||||
func TestBrowserStackUsesInProcessGPU(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ① 必须带 --in-process-gpu,否则在加固环境下浏览器起不来
|
||||
if !strings.Contains(b, "--in-process-gpu") {
|
||||
t.Fatal("chromium 命令行必须带 --in-process-gpu —— 否则在 " +
|
||||
"SystemCallFilter=@system-service 的加固环境下 GPU 进程起不来、" +
|
||||
"浏览器自杀、VNC 永远白屏(真实事故,2026-10-14)")
|
||||
}
|
||||
// ② 必须给可写的 HOME/XDG:ProtectHome=yes 下 /root 只读,
|
||||
// chromium 建 dconf/ibus 目录会持续刷 permission denied 噪声
|
||||
for _, k := range []string{"XDG_CACHE_HOME=", "XDG_CONFIG_HOME="} {
|
||||
if !strings.Contains(b, k) {
|
||||
t.Errorf("chromium 命令行应设置 %s(加固环境下 /root 只读会产生噪声日志)", k)
|
||||
}
|
||||
}
|
||||
// ③ **存活探针**必须存在:端口在听 ≠ 浏览器可用。
|
||||
// 没有它时,"浏览器已死"会被谎报成"已就绪",运维打开 VNC 是白屏,
|
||||
// 还会以为是自己操作的问题。谎报比直接报错更坏。
|
||||
if !strings.Contains(b, "browserUtil.cdpAlive") {
|
||||
t.Error("起栈后必须做 CDP 存活探针 —— 只等端口就报就绪会把白屏谎报成成功")
|
||||
}
|
||||
if !strings.Contains(b, "browserUtil.readTail") {
|
||||
t.Error("启动失败必须把日志真因带给用户,而不是一句「启动失败」")
|
||||
}
|
||||
// ④ 失败必须回收整组进程(否则每次失败都留一套 Xvfb/x11vnc/websockify)
|
||||
idx := strings.Index(b, "浏览器启动后即退出")
|
||||
if idx < 0 {
|
||||
t.Fatal("应有「浏览器启动后即退出」的失败分支")
|
||||
}
|
||||
if !strings.Contains(b[idx:idx+600], "reapBrowser()") {
|
||||
t.Error("起栈失败必须先回收已起的进程(否则泄漏一套 Xvfb/x11vnc/websockify)")
|
||||
}
|
||||
}
|
||||
70
internal/core/channels_vnc_lifecycle_test.go
Normal file
70
internal/core/channels_vnc_lifecycle_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// VNC 会话生命周期的**前向引用**判据(真实 bug 的守卫)。
|
||||
//
|
||||
// `sweepSessions` 里调用了 reapLoopback / browserStack / reapBrowser,而这三个
|
||||
// 助手定义在**更后面**。Lua 的 `local` 只从定义处向后可见 ⇒ 未前向声明时它们是
|
||||
// **全局 nil**:加载期不报错,只有真正走到"会话过期回收"那条分支才炸:
|
||||
//
|
||||
// attempt to call global 'reapLoopback' (a nil value)
|
||||
//
|
||||
// 症状极具迷惑性:**第一次登录完全正常**(没有过期会话可回收),第二次才 500,
|
||||
// 且已经起来的浏览器栈**不会被回收**(reapBrowser 同样调不到)⇒ 进程与端口泄漏。
|
||||
// 本文件此前已因同一个坑踩过 12 次,故这里钉住"三者都必须有前向声明"。
|
||||
func TestSweepSessionsHelpersAreForwardDeclared(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ① 必须存在对这三个助手的前向声明
|
||||
if !strings.Contains(b, "local reapLoopback, browserStack, reapBrowser") {
|
||||
t.Fatal("sweepSessions 用到的三个助手必须**前向声明** —— 否则 " +
|
||||
"`local` 只从定义处向后可见,运行到该分支时是 nil 调用(第一次登录" +
|
||||
"正常、第二次 500,且浏览器栈不会被回收)")
|
||||
}
|
||||
// ② 定义处必须是**赋值**形态(绑定到那个 local),不能又是 `local function`
|
||||
// —— 那会遮蔽前向声明,效果与没声明完全相同。
|
||||
for _, fn := range []string{"browserStack", "reapBrowser", "reapLoopback"} {
|
||||
if strings.Contains(b, "local function "+fn+"(") {
|
||||
t.Errorf("%s 的定必须是 `%s = function(...)` 形式(绑定前向声明);"+
|
||||
"再用 `local function` 会另起一个 local,前向声明形同虚设", fn, fn)
|
||||
}
|
||||
if !strings.Contains(b, "\n"+fn+" = function(") {
|
||||
t.Errorf("找不到 %s 的赋值式定义", fn)
|
||||
}
|
||||
}
|
||||
// ③ 声明必须**先于**使用(行号比较):这是前向引用的核心
|
||||
decl := strings.Index(b, "local reapLoopback, browserStack, reapBrowser")
|
||||
use := strings.Index(b, "\n reapLoopback(id)\n")
|
||||
if decl < 0 || use < 0 {
|
||||
t.Fatalf("找不到声明(%d)或使用(%d)", decl, use)
|
||||
}
|
||||
if decl > use {
|
||||
t.Error("前向声明必须在**使用之前** —— 否则 nil 调用")
|
||||
}
|
||||
}
|
||||
|
||||
// 浏览器栈的**启动后存活**必须有真实判据(谎报"就绪"比直接报错更坏)。
|
||||
func TestBrowserStackVerifiesLivenessNotJustPort(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// CDP 真应答(端口在听 ≠ 服务可用)
|
||||
if !strings.Contains(b, "browserUtil.cdpAlive") {
|
||||
t.Error("必须用 CDP 真应答判活,不能只看端口")
|
||||
}
|
||||
// 进程真存活
|
||||
if !strings.Contains(b, "/proc/") {
|
||||
t.Error("必须检查 chromium 进程仍存活(/proc/<pid>)")
|
||||
}
|
||||
// 失败要把日志真因带出来(而不是一句"启动失败")
|
||||
if !strings.Contains(b, "browserUtil.readTail") {
|
||||
t.Error("启动失败必须带出日志真因")
|
||||
}
|
||||
}
|
||||
153
internal/core/channels_vnc_test.go
Normal file
153
internal/core/channels_vnc_test.go
Normal file
@ -0,0 +1,153 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通:
|
||||
// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口)
|
||||
// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。
|
||||
//
|
||||
// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。
|
||||
// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。
|
||||
func TestChannelsVncStackEndToEnd(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
|
||||
// 依赖预检:缺件则跳过(并让日志说明)
|
||||
depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("deps_status: %v", err)
|
||||
}
|
||||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"])
|
||||
}
|
||||
|
||||
// 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb",
|
||||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai",
|
||||
"models": ["v-model-1"]
|
||||
}]`)
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
if e, _ := m["browser_error"].(string); e != "" {
|
||||
t.Fatalf("浏览器栈未能启动: %s", e)
|
||||
}
|
||||
view, _ := m["view_url"].(string)
|
||||
if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") {
|
||||
t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view)
|
||||
}
|
||||
sess := m["session"].(map[string]interface{})
|
||||
if sess["cdp_port"] == nil {
|
||||
t.Fatal("会话应带 CDP 端口(取凭据用)")
|
||||
}
|
||||
|
||||
// ★ noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()),
|
||||
// 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。
|
||||
// 真实缺陷:传了站点根路径 ⇒ 路径被重复拼接,WebSocket 永远连不上,
|
||||
// 页面看似白屏:
|
||||
// .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify
|
||||
// 本判据必须解析**产品自己给出的 URL**:我曾在验证时手拼 URL 并省掉
|
||||
// path 参数(默认值恰好正确)⇒ 否定了真实缺陷。
|
||||
u, perr := url.Parse(view)
|
||||
if perr != nil {
|
||||
t.Fatalf("view_url 不可解析: %v", perr)
|
||||
}
|
||||
q := u.Query()
|
||||
wspath := q.Get("path")
|
||||
if wspath == "" {
|
||||
t.Fatal("view_url 必须显式声明 path=websockify:依赖 noVNC 隐式默认值" +
|
||||
"会在 URL 形状变化时静默破裂")
|
||||
}
|
||||
if strings.HasPrefix(wspath, "/") {
|
||||
t.Fatalf("path 必须是相对名(noVNC 相对 vnc.html 目录解析),"+
|
||||
"以 / 开头会被当相对路径而重复拼接,实得 %q", wspath)
|
||||
}
|
||||
resolved := u.ResolveReference(&url.URL{Path: wspath})
|
||||
pages := strings.TrimSuffix(u.Path, "/vnc.html")
|
||||
if resolved.Path != pages+"/websockify" {
|
||||
t.Fatalf("path 解析后应为 %q(同级 websockify),实得 %q —— 重复拼接即白屏根因",
|
||||
pages+"/websockify", resolved.Path)
|
||||
}
|
||||
|
||||
t.Logf("VNC 栈已起: %s → WebSocket %s (CDP %v)", view, resolved.Path, sess["cdp_port"])
|
||||
|
||||
// 收尾:回收浏览器栈(测试不留孤儿进程)
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。
|
||||
//
|
||||
// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids;
|
||||
// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存
|
||||
// (实测一次跑测试留下 4 个 orphan Xvfb)。
|
||||
//
|
||||
// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的
|
||||
// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。
|
||||
func TestChannelsVncNoOrphanOnFailure(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
|
||||
depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Skip("跳过:缺浏览器栈依赖")
|
||||
}
|
||||
// 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行;
|
||||
// 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 ——
|
||||
// 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb",
|
||||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"]
|
||||
}]`)
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" {
|
||||
t.Skipf("本机浏览器栈起不来(%s),跳过", e)
|
||||
}
|
||||
// 起来后立刻回收
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 回收后状态必须清零
|
||||
st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if st.JSON.(map[string]interface{})["running"] != false {
|
||||
t.Fatal("stop_browser 后 running 应为 false")
|
||||
}
|
||||
}
|
||||
203
internal/core/channels_xdisplay_test.go
Normal file
203
internal/core/channels_xdisplay_test.go
Normal file
@ -0,0 +1,203 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。
|
||||
//
|
||||
// 现场(2026-10-13):/tmp/.X<n>-lock 在 Xvfb 被 SIGKILL 后会留下(插件收尾
|
||||
// 就是 `kill -9`)。而旧选号逻辑是「锁/socket 存在就当被占」→ 每个被强杀的
|
||||
// 显示器**永久烧掉一个号**。反复跑测试/反复登录后 91 起的整段号被占满
|
||||
// (实测 41/41),于是 pickFree 扫不到空位 → `return start`(91) → Xvfb 报
|
||||
// “Server is already active for display 91”。
|
||||
// 表现为 TestChannelsVncStackEndToEnd 单独 1.07s 通过、全量跑 15s 超时失败。
|
||||
//
|
||||
// 判据是**行为**,分三步(注意:插件占用期间锁存在是**正确**的 —— Xvfb 自己
|
||||
// 会写锁;错的是「用完之后不清理」和「被别人的陈旧锁挡死」):
|
||||
// 1. 陈旧锁不得挡住选号(记录 PID 已死)→ 能正常起来,且不报 already active;
|
||||
// 2. 空闲号被选中后,锁里应是**真实存活的** PID(即插件真占用了它);
|
||||
// 3. 收栈之后,该号的锁与 socket 必须被清掉(这才是泄漏的根治)。
|
||||
func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
|
||||
const ghost = "999999" // 必然不存在的 PID
|
||||
// 造 3 个陈旧锁,并要求插件**复用**其中一个。
|
||||
// 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。
|
||||
//
|
||||
// ⚠️ 号段必须**跟着测试台的 XDISPLAY_BASE 走**(不是硬编码 91/92/93)。
|
||||
// 原因:生产 unit 带 PrivateTmp,它起的 Xvfb :91 的锁在服务自己的 /tmp 里,
|
||||
// 宿主测试看不到 ⇒ 测试会去抢 91 并失败。测试台因此把起点挪到 300
|
||||
// (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的
|
||||
// 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。
|
||||
base := 300
|
||||
if v := os.Getenv("LLMSPROXY_XDISPLAY_BASE"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
base = n
|
||||
}
|
||||
}
|
||||
ghosts := []int{base, base + 1, base + 2}
|
||||
for _, n := range ghosts {
|
||||
l := "/tmp/.X" + strconv.Itoa(n) + "-lock"
|
||||
if _, err := os.Stat(l); err == nil {
|
||||
t.Skipf("前置条件不满足:%s 已存在(先清理测试环境再跑)", l)
|
||||
}
|
||||
if err := os.WriteFile(l, []byte(ghost+"\n"), 0o644); err != nil {
|
||||
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
|
||||
}
|
||||
defer os.Remove(l)
|
||||
}
|
||||
lock := "/tmp/.X" + strconv.Itoa(base) + "-lock"
|
||||
sock := "/tmp/.X11-unix/X" + strconv.Itoa(base)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 12 个真实平台里已无 vnc 流,故注入一个夹具(夹具不写进生产目录)。
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "xdispfx", "name": "显示器夹具", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "http://127.0.0.1:18099/login",
|
||||
"base_url": "https://api.xdispfx.example/v1", "adapter": "openai",
|
||||
"models": ["x-model-1"]
|
||||
}]`)
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"xdispfx"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
j, _ := out.JSON.(map[string]interface{})
|
||||
if j == nil {
|
||||
t.Fatalf("无 JSON 响应 (status=%d)", out.Status)
|
||||
}
|
||||
msg, _ := j["browser_error"].(string)
|
||||
if strings.Contains(msg, "already active for display") {
|
||||
t.Fatalf("陈旧锁把选号挡死了(应回收):%s", msg)
|
||||
}
|
||||
if msg != "" {
|
||||
// 缺浏览器依赖起不来是允许的 —— 但陈旧锁仍必须被回收(不占号)。
|
||||
if _, err := os.Stat(lock); err == nil {
|
||||
t.Errorf("陈旧锁未被回收: %s(记录 PID %s 已不存在)", lock, ghost)
|
||||
}
|
||||
t.Skipf("浏览器栈未启动(与选号无关): %s", msg)
|
||||
}
|
||||
|
||||
// ② 插件确实占用了某个显示器,且锁里是**活着的** PID(不是那个幽灵)。
|
||||
st, err := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
|
||||
Method: "GET", Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("browser_status: %v", err)
|
||||
}
|
||||
sess, _ := st.JSON.(map[string]interface{})["session"].(map[string]interface{})
|
||||
used := 0
|
||||
if sess != nil {
|
||||
if f, ok := sess["display"].(float64); ok {
|
||||
used = int(f)
|
||||
}
|
||||
}
|
||||
if used == 0 {
|
||||
t.Fatalf("会话未报告 display: %v", st.JSON)
|
||||
}
|
||||
// 关键断言:选中的必须是那 3 个陈旧号之一 —— 证明陈旧锁被**回收复用**了。
|
||||
// 只跳过不复用的实现(变异)会选 94,从而被抓到。
|
||||
reclaimed := false
|
||||
for _, n := range ghosts {
|
||||
if used == n {
|
||||
reclaimed = true
|
||||
}
|
||||
}
|
||||
if !reclaimed {
|
||||
t.Errorf("选中的 :%d 不是陈旧号 %v —— 陈旧锁没被回收复用(号段会被逐个烧掉,最终饿死)", used, ghosts)
|
||||
}
|
||||
if raw, err := os.ReadFile("/tmp/.X" + strconv.Itoa(used) + "-lock"); err == nil {
|
||||
pid := strings.TrimSpace(string(raw))
|
||||
if pid == ghost {
|
||||
t.Errorf("选中的显示器 :%d 锁里仍是幽灵 PID(陈旧锁没被换掉)", used)
|
||||
} else if pid != "" {
|
||||
if _, err := os.Stat("/proc/" + pid); err != nil {
|
||||
t.Errorf("选中的显示器 :%d 锁里 PID %s 不存在(记录与事实不符)", used, pid)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Logf(":%d 未生成锁文件(部分 Xvfb 行为,可接受)", used)
|
||||
}
|
||||
|
||||
// ③ 收栈后必须不留锁/socket —— 这条直接钉住「用 kill -9 收尾却不清理」的泄漏。
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
dl := "/tmp/.X" + strconv.Itoa(used) + "-lock"
|
||||
ds := "/tmp/.X11-unix/X" + strconv.Itoa(used)
|
||||
if _, err := os.Stat(dl); err == nil {
|
||||
t.Errorf("收栈后锁未清理: %s(下次会把这个号误判为被占 → 号被永久烧掉)", dl)
|
||||
_ = os.Remove(dl)
|
||||
}
|
||||
if _, err := os.Stat(ds); err == nil {
|
||||
t.Errorf("收栈后 socket 未清理: %s", ds)
|
||||
_ = os.Remove(ds)
|
||||
}
|
||||
_ = sock
|
||||
}
|
||||
|
||||
// 反向判据:**活着的**占用不得被回收(否则会把别人正在用的显示器抢走)。
|
||||
func TestXDisplaySelectionKeepsLiveLocks(t *testing.T) {
|
||||
const disp = 92
|
||||
lock := "/tmp/.X" + strconv.Itoa(disp) + "-lock"
|
||||
if _, err := os.Stat(lock); err == nil {
|
||||
t.Skipf("前置条件不满足:%s 已存在", lock)
|
||||
}
|
||||
// 用一个**真实存活**的 PID(本进程)写锁。
|
||||
if err := os.WriteFile(lock, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
|
||||
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
|
||||
}
|
||||
defer os.Remove(lock)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "xdispfx2", "name": "显示器夹具2", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "http://127.0.0.1:18097/login",
|
||||
"base_url": "https://api.xdispfx2.example/v1", "adapter": "openai",
|
||||
"models": ["x-model-2"]
|
||||
}]`)
|
||||
defer func() {
|
||||
_, _ = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
})
|
||||
}()
|
||||
|
||||
if _, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"xdispfx2"}`, Role: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
|
||||
// 记录的是活 PID → 该锁必须原样保留(不得抢占别人正在用的显示器)。
|
||||
raw, err := os.ReadFile(lock)
|
||||
if err != nil {
|
||||
t.Fatalf("活锁被误回收: %s(记录的是真实存活 PID,不得回收)", lock)
|
||||
}
|
||||
if got := strings.TrimSpace(string(raw)); got != strconv.Itoa(os.Getpid()) {
|
||||
t.Errorf("活锁内容被改动: %q(应保持原样)", got)
|
||||
}
|
||||
}
|
||||
188
internal/core/channels_zcode_test.go
Normal file
188
internal/core/channels_zcode_test.go
Normal file
@ -0,0 +1,188 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。
|
||||
//
|
||||
// 参考语义(zcode-login.ts 逐行):
|
||||
//
|
||||
// ① POST /api/v1/oauth/cli/init → {flow_id, authorize_url, poll_interval_sec}
|
||||
// Authorization 是**本地造的会话密钥**(不是用户凭据)
|
||||
// ② 用户在浏览器打开 authorize_url
|
||||
// ③ GET /api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
|
||||
// 判据:4xx(除 408/429)= 终态失败;5xx/网络错误 = 继续等;
|
||||
// code!=0 或 data 缺失 = pending;status=pending = 继续
|
||||
//
|
||||
// 此前标 flow="manual"(理由"要解 captcha"),但该授权流**完全不涉及 captcha**
|
||||
// —— captcha 属于另一条已废弃的旁路(读取本机官方客户端数据)。
|
||||
type zcodeHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
polls int32
|
||||
readyAt int32
|
||||
gotInit map[string]interface{}
|
||||
gotAuth string
|
||||
}
|
||||
|
||||
func newZcodeHarness(t *testing.T) *zcodeHarness {
|
||||
t.Helper()
|
||||
h := &zcodeHarness{t: t, readyAt: 2}
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == "POST" && r.URL.Path == "/api/v1/oauth/cli/init":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &h.gotInit)
|
||||
h.gotAuth = r.Header.Get("Authorization")
|
||||
fmt.Fprint(w, `{"code":0,"data":{"flow_id":"FLOW-1","poll_token":"PT",
|
||||
"authorize_url":"https://bigmodel.cn/login?appId=zcode&redirect=x",
|
||||
"expires_at":9999999999,"poll_interval_sec":2}}`)
|
||||
case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v1/oauth/cli/poll/"):
|
||||
if !strings.HasSuffix(r.URL.Path, "FLOW-1") {
|
||||
fmt.Fprint(w, `{"code":1,"msg":"unknown flow"}`)
|
||||
return
|
||||
}
|
||||
n := atomic.AddInt32(&h.polls, 1)
|
||||
if n < atomic.LoadInt32(&h.readyAt) {
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"pending"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0,"data":{"status":"ready",
|
||||
"token":"ZCODE-JWT-1",
|
||||
"user":{"user_id":"u7","name":"zcode-dev"},
|
||||
"bigmodel":{"access_token":"BM-AT-1","refresh_token":"BM-RT-1"}}}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "zcode",
|
||||
"initpoll": map[string]interface{}{
|
||||
"init_url": h.ts.URL + "/api/v1/oauth/cli/init",
|
||||
"poll_url": h.ts.URL + "/api/v1/oauth/cli/poll/{flow_id}",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *zcodeHarness) begin() (map[string]interface{}, string) {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
func (h *zcodeHarness) pollOnce(sid string) map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestZcodeInitPollBehavior 逐行锁定参考的 ZCode 授权流语义。
|
||||
func TestZcodeInitPollBehavior(t *testing.T) {
|
||||
h := newZcodeHarness(t)
|
||||
|
||||
t.Run("行1 init带本地会话密钥与provider", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
j, _ := h.begin()
|
||||
if h.gotInit["provider"] != "bigmodel" {
|
||||
t.Fatalf("init body 应带 provider=bigmodel, 实得 %v", h.gotInit)
|
||||
}
|
||||
// Authorization 是我们自己生成的会话密钥(Bearer 后非空),不是用户凭据
|
||||
if !strings.HasPrefix(h.gotAuth, "Bearer ") || len(h.gotAuth) < 20 {
|
||||
t.Fatalf("init 应带本地会话密钥作 Bearer, 实得 %q", h.gotAuth)
|
||||
}
|
||||
if u, _ := j["url"].(string); !strings.Contains(u, "bigmodel.cn") {
|
||||
t.Fatalf("应给出 authorize_url, 实得 %q", u)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行2 pending状态保持pending", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.readyAt, 99)
|
||||
_, sid := h.begin()
|
||||
for i := 0; i < 3; i++ {
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("status=pending 应保持 pending, 实得 %v", st["state"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行3 ready即完成_凭据含zcodeJwt与第三方token", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.readyAt, 2)
|
||||
_, sid := h.begin()
|
||||
if st := h.pollOnce(sid); st["state"] != "pending" {
|
||||
t.Fatalf("首拍应 pending: %v", st)
|
||||
}
|
||||
st := h.pollOnce(sid)
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("ready 应 completed, 实得 %v", st)
|
||||
}
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:zcode")
|
||||
if !ok {
|
||||
t.Fatal("凭据未落库")
|
||||
}
|
||||
for _, want := range []string{"ZCODE-JWT-1", "BM-AT-1", "zcode-dev"} {
|
||||
if !strings.Contains(v, want) {
|
||||
t.Fatalf("凭据应含 %q, 实得 %s", want, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("行4 池路径_凭据落账号ref", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.polls, 0)
|
||||
atomic.StoreInt32(&h.readyAt, 1)
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"zcode","account_id":%q}`, aid), Role: "admin",
|
||||
})
|
||||
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
|
||||
if st := h.pollOnce(sid); st["state"] != "completed" {
|
||||
t.Fatalf("池路径应完成: %v", st)
|
||||
}
|
||||
if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok {
|
||||
t.Fatalf("池路径凭据未落到 channels:zcode:%s", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
38
internal/core/codearts_sig_expected.json
Normal file
38
internal/core/codearts_sig_expected.json
Normal file
@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
|
||||
"body": "",
|
||||
"ak": "AK1",
|
||||
"sk": "SK1",
|
||||
"st": "TOK1",
|
||||
"canonical": "GET\n/snap-manager/v1/statistics/plugin/\n\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:TOK1\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\nf40918a3339c9db2251933dcce410db05ff8a403de2767bc642140ea93cdc182",
|
||||
"signature": "5332d627c3b8f14973afe41905f23dce75eaf9a34134dff670cb19835beb0549",
|
||||
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE",
|
||||
"body": "",
|
||||
"ak": "AK2",
|
||||
"sk": "SK2",
|
||||
"st": "",
|
||||
"canonical": "GET\n/v1/ops/delivery/\nchannel=IDE\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n3c849b40bbad69b981eaf280e162878bf00687d08cb45df43ac792fc23add4f1",
|
||||
"signature": "a01248a4121fd943f0f3f07aac101cd649ddf34d162729bf06a6a12ae9916b1a",
|
||||
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim",
|
||||
"body": "{\"campaignId\":\"C1\",\"channel\":\"IDE\"}",
|
||||
"ak": "AK3",
|
||||
"sk": "SK3",
|
||||
"st": "T3",
|
||||
"canonical": "POST\n/v1/ops/claim/\n\ncontent-type:application/json\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19\nx-sdk-date:20261013T150405Z\nx-security-token:T3\n\ncontent-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token\n85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19",
|
||||
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n554c2607cff324a770b55e78e2a1c58afe466abf5e4eaa104a6e1fa96acfc733",
|
||||
"signature": "e5faa52ec51c36b38804aac45a8a19876314cec465ab8f905100e657e8ea68d5",
|
||||
"signed_headers": "content-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token"
|
||||
}
|
||||
]
|
||||
97
internal/core/codearts_sign_test.go
Normal file
97
internal/core/codearts_sign_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CodeArts 的华为云 SDK-HMAC-SHA256 签名必须与参考**逐字节一致**,否则服务端
|
||||
// 返回 `verify ak sk signature fail`,而这个平台的领取就永远失败 —— 且失败原因
|
||||
// 会被误读成"账号问题"。此前插件里的实现是凭印象编的另一套形状
|
||||
// (Credential={ak}/{ts}/{nonce}/sdk_request),必然验签失败。
|
||||
//
|
||||
// 期望值由参考实现(sign.ts:signRequestHuawei,Node + node:crypto)生成:
|
||||
//
|
||||
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
|
||||
//
|
||||
// 判据覆盖:canonical request 的拼装、string-to-sign 格式、最终 signature、
|
||||
// 以及参与签名的头列表(顺序与内容)。
|
||||
func TestCodeArtsSignatureMatchesReference(t *testing.T) {
|
||||
raw, err := os.ReadFile("codearts_sig_expected.json")
|
||||
if err != nil {
|
||||
t.Skipf("缺差分数据(跳过): %v", err)
|
||||
}
|
||||
var cases []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body string `json:"body"`
|
||||
AK string `json:"ak"`
|
||||
SK string `json:"sk"`
|
||||
ST string `json:"st"`
|
||||
Canonical string `json:"canonical"`
|
||||
StringToSign string `json:"string_to_sign"`
|
||||
Signature string `json:"signature"`
|
||||
SignedHeaders string `json:"signed_headers"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases) == 0 {
|
||||
t.Fatal("差分数据为空")
|
||||
}
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i, tc := range cases {
|
||||
got, err := ps.Call("channels", "__codearts_sign_probe", map[string]interface{}{
|
||||
"method": tc.Method, "url": tc.URL, "body": tc.Body,
|
||||
"ak": tc.AK, "sk": tc.SK, "st": tc.ST, "date": "20261013T150405Z",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("case %d probe: %v", i, err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("case %d 无 json: %#v", i, got)
|
||||
}
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("case %d 探针报错: %v", i, m["error"])
|
||||
}
|
||||
if s, _ := m["canonical"].(string); s != tc.Canonical {
|
||||
t.Errorf("case %d canonical 不一致\n 实得: %q\n 参考: %q", i, s, tc.Canonical)
|
||||
}
|
||||
if s, _ := m["string_to_sign"].(string); s != tc.StringToSign {
|
||||
t.Errorf("case %d string-to-sign 不一致\n 实得: %q\n 参考: %q", i, s, tc.StringToSign)
|
||||
}
|
||||
if s, _ := m["signature"].(string); s != tc.Signature {
|
||||
t.Errorf("case %d signature 不一致: 实得 %s 参考 %s", i, s, tc.Signature)
|
||||
}
|
||||
if s, _ := m["signed_headers"].(string); s != tc.SignedHeaders {
|
||||
t.Errorf("case %d SignedHeaders 不一致: 实得 %q 参考 %q", i, s, tc.SignedHeaders)
|
||||
}
|
||||
// Authorization 头必须是 `SDK-HMAC-SHA256 Access=...`(不是 Credential=)
|
||||
if s, _ := m["authorization"].(string); len(s) == 0 ||
|
||||
s[:len("SDK-HMAC-SHA256 Access=")] != "SDK-HMAC-SHA256 Access=" {
|
||||
t.Errorf("case %d Authorization 形状应为 SDK-HMAC-SHA256 Access=…,实得 %q", i, s)
|
||||
}
|
||||
// 签名后追加的头绝不能进 canonical
|
||||
if s, _ := m["canonical"].(string); len(s) > 0 {
|
||||
for _, bad := range []string{"Agent-Type", "X-Language", "agent-type", "x-language"} {
|
||||
if strings.Contains(s, bad) {
|
||||
t.Errorf("case %d canonical 里出现了签名后追加的头 %s(会导致验签失败)", i, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -20,10 +20,29 @@ import (
|
||||
func kernelTestCore(t *testing.T) *Core {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
// 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
|
||||
// LLMSPROXY_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
|
||||
// /etc/llmsproxy/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
|
||||
// 把 26 个测试账号写进了生产 .channels-store.json)。
|
||||
// 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
}
|
||||
// ★ 显示器号与端口用**高段号**,避开生产。
|
||||
//
|
||||
// 真实事故(2026-10-15):生产 unit 带 `PrivateTmp=yes`,它起的 Xvfb :91 的
|
||||
// 锁文件在**服务自己的 /tmp** 里 —— 宿主上的测试看不到它,于是判"91 空闲"
|
||||
// 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着
|
||||
// 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。
|
||||
// 故测试整体挪到 300+。
|
||||
if os.Getenv("LLMSPROXY_XDISPLAY_BASE") == "" {
|
||||
t.Setenv("LLMSPROXY_XDISPLAY_BASE", "300")
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
PluginDir: filepath.Join(dir, "plugins"),
|
||||
PluginDir: pluginDir,
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-kernel-0001"},
|
||||
@ -37,6 +56,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
|
||||
}
|
||||
|
||||
@ -34,6 +34,6 @@ return plugin
|
||||
t.Fatalf("动作未成功: %#v", m)
|
||||
}
|
||||
if _, ok := c.FindKey("sk-kernel-0001"); ok {
|
||||
t.Fatal("★ 动作报告成功但副作用没发生 —— dispatch 是空转")
|
||||
t.Fatal("动作报告成功但副作用没发生 —— dispatch 是空转")
|
||||
}
|
||||
}
|
||||
|
||||
22
internal/core/mutation_guard_test.go
Normal file
22
internal/core/mutation_guard_test.go
Normal file
@ -0,0 +1,22 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 把测试进程的时区钉成 **UTC**。
|
||||
//
|
||||
// 为什么必需:Qoder 的每日活动按 **UTC+8** 结算,判定必须用算术平移而不是
|
||||
// 本机时区。但这台机器**本身就是 HKT(+0800)** —— 于是在真实环境里
|
||||
// 「os.date("!*t")(UTC 平移)」与「os.date("*t")(本机时区)」结果**完全相同**,
|
||||
// 判据无法区分二者(实测:把实现改成用本机时区,判据照样通过)。
|
||||
// 钉成 UTC 后两者相差 8 小时,误用本机时区的实现立刻暴露。
|
||||
//
|
||||
// 只影响测试进程,不改系统时区。
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv("TZ", "UTC")
|
||||
time.Local = time.UTC
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
44
internal/core/trae_derive_expected.json
Normal file
44
internal/core/trae_derive_expected.json
Normal 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"
|
||||
}
|
||||
]
|
||||
@ -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 {
|
||||
|
||||
71
internal/gateway/mutation_guard_test.go
Normal file
71
internal/gateway/mutation_guard_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -55,6 +55,15 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
for i := range matches {
|
||||
h := matches[i]
|
||||
p, ok := matchHandlerPath(h.Path, rest)
|
||||
if h.Prefix {
|
||||
pattern := strings.Trim(h.Path, "/")
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
n := len(strings.Split(pattern, "/"))
|
||||
ok = len(parts) >= n
|
||||
if ok {
|
||||
p, ok = matchHandlerPath(pattern, strings.Join(parts[:n], "/"))
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@ -116,6 +125,16 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
if resp.Proxy != "" {
|
||||
if !chosen.Prefix {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", "proxy response requires prefix=true")
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
n := len(strings.Split(strings.Trim(chosen.Path, "/"), "/"))
|
||||
g.servePluginProxy(w, r, resp.Proxy, pluginHTTPPrefix+strings.Join(parts[:n], "/"))
|
||||
return
|
||||
}
|
||||
lua.WriteHandlerResponse(w, resp)
|
||||
}
|
||||
|
||||
|
||||
54
internal/gateway/plugin_proxy.go
Normal file
54
internal/gateway/plugin_proxy.go
Normal file
@ -0,0 +1,54 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// servePluginProxy forwards a plugin-owned prefix to an explicit loopback
|
||||
// origin. No destination or path may be supplied by the client. Authentication
|
||||
// and endpoint authorization have already completed before this is called.
|
||||
func (g *Gateway) servePluginProxy(w http.ResponseWriter, r *http.Request, target, prefix string) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy must name a loopback HTTP origin")
|
||||
return
|
||||
}
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
if ip == nil || !ip.IsLoopback() || u.Port() == "" {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy requires a literal loopback address and port")
|
||||
return
|
||||
}
|
||||
// WebSocket requests are browser-initiated and carry ambient cookies. Do not
|
||||
// allow a foreign website to use an administrator's session to open a tunnel.
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
o, e := url.Parse(origin)
|
||||
if e != nil || !strings.EqualFold(o.Host, r.Host) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "cross-origin proxy request refused")
|
||||
return
|
||||
}
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(u)
|
||||
director := proxy.Director
|
||||
proxy.Director = func(out *http.Request) {
|
||||
director(out)
|
||||
out.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if out.URL.Path == "" {
|
||||
out.URL.Path = "/"
|
||||
}
|
||||
out.URL.RawPath = ""
|
||||
out.Host = u.Host
|
||||
out.Header.Del("Authorization")
|
||||
out.Header.Del("Cookie")
|
||||
q := out.URL.Query()
|
||||
q.Del("api_key")
|
||||
out.URL.RawQuery = q.Encode()
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "plugin service unavailable")
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
66
internal/gateway/plugin_proxy_security_test.go
Normal file
66
internal/gateway/plugin_proxy_security_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginProxyRejectsUnsafeTargetsAndOrigin(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
for _, target := range []string{"http://example.com:80", "http://localhost:80", "https://127.0.0.1:80", "http://127.0.0.1", "http://u:p@127.0.0.1:80", "http://127.0.0.1:80/path", "http://127.0.0.1:80/?x=1"} {
|
||||
rr := httptest.NewRecorder()
|
||||
g.servePluginProxy(rr, httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/live.js", nil), target, "/api/plugins/http/relay/s")
|
||||
if rr.Code != 502 {
|
||||
t.Errorf("unsafe target %q accepted: %d", target, rr.Code)
|
||||
}
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/websockify", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
g.servePluginProxy(rr, req, "http://127.0.0.1:12345", "/api/plugins/http/relay/s")
|
||||
if rr.Code != 403 {
|
||||
t.Fatalf("foreign origin accepted: %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPrefixProxyAuthorizationAndRevocation(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("denied request reached backend") }))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := `local p={name="relaydeny",http={{path="relaydeny/:session",prefix=true,roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req) return {status=404,body="expired session"} end
|
||||
return p`
|
||||
if err := g.core.Plugins().LoadSource("relaydeny", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
for _, tc := range []struct {
|
||||
path, key string
|
||||
status int
|
||||
}{
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "", 401},
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "sk-test", 404},
|
||||
{"/api/plugins/http/relaydeny-not-a-child/expired/vnc.html", "sk-test", 404},
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", server.URL+tc.path, nil)
|
||||
if tc.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tc.key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != tc.status {
|
||||
t.Fatalf("%s got %d %s", tc.path, resp.StatusCode, b)
|
||||
}
|
||||
if tc.key != "" && strings.Contains(tc.path, "/expired/") && strings.Contains(tc.path, "/relaydeny/") && string(b) != "expired session" {
|
||||
t.Fatalf("revocation response=%s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
internal/gateway/plugin_proxy_test.go
Normal file
82
internal/gateway/plugin_proxy_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Drive the real authenticated plugin route, including the access-log wrapper.
|
||||
// A successful static request alone does not prove WebSocket can upgrade.
|
||||
func TestPluginPrefixProxyHTTPAndUpgrade(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.URL.Query().Get("api_key") != "" {
|
||||
t.Error("gateway credentials leaked to plugin backend")
|
||||
}
|
||||
if r.URL.Path == "/websockify" {
|
||||
c, b, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprint(b, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
b.Flush()
|
||||
io.Copy(c, c)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, r.URL.Path+"?"+r.URL.RawQuery)
|
||||
}))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := fmt.Sprintf(`local p={name="relay",http={{path="relay/:session",prefix=true,method="GET",roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req)
|
||||
if req.params.session ~= "live" then return {status=404,body="expired"} end
|
||||
return {proxy=%q}
|
||||
end
|
||||
return p`, backend.URL)
|
||||
if err := g.core.Plugins().LoadSource("relay", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/plugins/http/relay/live/app/main.js?x=1&api_key=sk-test", nil)
|
||||
req.Header.Set("Authorization", "Bearer sk-test")
|
||||
req.AddCookie(&http.Cookie{Name: "gw_key", Value: "sk-test"})
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || string(body) != "/app/main.js?x=1" {
|
||||
t.Fatalf("HTTP proxy: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
c, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprintf(c, "GET /api/plugins/http/relay/live/websockify HTTP/1.1\r\nHost: %s\r\nAuthorization: Bearer sk-test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", strings.TrimPrefix(server.URL, "http://"))
|
||||
b := bufio.NewReader(c)
|
||||
reply, err := http.ReadResponse(b, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply.StatusCode != 101 {
|
||||
t.Fatalf("upgrade failed: %s", reply.Status)
|
||||
}
|
||||
c.Write([]byte("ping"))
|
||||
got := make([]byte, 4)
|
||||
if _, err = io.ReadFull(b, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "ping" {
|
||||
t.Fatalf("tunnel payload=%q", got)
|
||||
}
|
||||
}
|
||||
@ -187,6 +187,10 @@ type statusRecorder struct {
|
||||
code int
|
||||
}
|
||||
|
||||
// Unwrap preserves optional writer capabilities (notably Hijack for WebSocket)
|
||||
// through the access-log middleware via http.ResponseController.
|
||||
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
if s.code == 0 {
|
||||
s.code = code
|
||||
|
||||
@ -228,6 +228,26 @@ func incStatus(a *Stat, name string, r Req) {
|
||||
}
|
||||
}
|
||||
|
||||
// addStat merges an already aggregated row into a total. It deliberately
|
||||
// includes failed requests without a model: routing can fail before a model is
|
||||
// resolved, but that request still belongs in the gateway total.
|
||||
func addStat(dst, src *Stat) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
dst.Reqs += src.Reqs
|
||||
dst.OK += src.OK
|
||||
dst.Err += src.Err
|
||||
dst.Tokens += src.Tokens
|
||||
dst.Prompt += src.Prompt
|
||||
dst.Compl += src.Compl
|
||||
dst.LatSum += src.LatSum
|
||||
dst.FirstByteSum += src.FirstByteSum
|
||||
if src.LatMax > dst.LatMax {
|
||||
dst.LatMax = src.LatMax
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAudit primes the in-memory state from the audit file.
|
||||
//
|
||||
// The aggregates (totals, per-key/model/source rows, status counts and the
|
||||
@ -1219,17 +1239,13 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
byModel = s.byKeyModel[key]
|
||||
bySrc = s.byKeySrc[key]
|
||||
}
|
||||
for _, a := range byModel {
|
||||
total.Reqs += a.Reqs
|
||||
total.OK += a.OK
|
||||
total.Err += a.Err
|
||||
total.Tokens += a.Tokens
|
||||
total.Prompt += a.Prompt
|
||||
total.Compl += a.Compl
|
||||
total.LatSum += a.LatSum
|
||||
if a.LatMax > total.LatMax {
|
||||
total.LatMax = a.LatMax
|
||||
}
|
||||
// The total is the sum of every recorded request, not the model rows.
|
||||
// Routing failures are valid requests but may have no resolved model; deriving
|
||||
// total from byModel silently dropped those 502/503 records. Use byKey, which
|
||||
// is populated for every request (including an empty key), and keep the same
|
||||
// scope as the selected key filter.
|
||||
for _, a := range byKey {
|
||||
addStat(&total, a)
|
||||
}
|
||||
bs := make([]agrRow, 0, len(s.byStatus))
|
||||
for code, st := range s.byStatus {
|
||||
|
||||
@ -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'")
|
||||
}
|
||||
}
|
||||
|
||||
@ -560,3 +560,17 @@ func TestAuditRotationThresholds(t *testing.T) {
|
||||
t.Fatalf("total audit budget = %d, want 256 MiB", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotTotalIncludesRequestsWithoutModel(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
s.Record(Req{Key: "k", Status: 200, OK: true})
|
||||
s.Record(Req{Key: "k", Model: "m", Status: 200, OK: true, Prompt: 3, Compl: 2})
|
||||
snap := s.Snapshot(0, "")
|
||||
total, ok := snap["total"].(Stat)
|
||||
if !ok {
|
||||
t.Fatalf("total missing: %#v", snap["total"])
|
||||
}
|
||||
if total.Reqs != 2 || total.OK != 2 || total.Tokens != 5 {
|
||||
t.Fatalf("total must include model-less requests: %#v", total)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
213
internal/lua/adapter_opencodezen_live_test.go
Normal file
213
internal/lua/adapter_opencodezen_live_test.go
Normal file
@ -0,0 +1,213 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。
|
||||
// 需要真实凭据(从生产配置解密),故用 LLMSPROXY_LIVE_ZEN=1 显式开启。
|
||||
func TestOpencodeZenLiveE2E(t *testing.T) {
|
||||
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
|
||||
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启真上游 e2e")
|
||||
}
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 取生产凭据(解密)
|
||||
out, err := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets",
|
||||
"-config", "/etc/llmsproxy/config.yaml").Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apiKey := ""
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "source localzen") {
|
||||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
t.Fatal("未取到 localzen 凭据")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"纯对话(无 tools,靠适配器兜底注入)",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"reply with exactly: ADAPTER-OK"}]}`, "ADAPTER-OK"},
|
||||
{"带自定义 system",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"reply with exactly: SYS-OK"}]}`, "SYS-OK"},
|
||||
{"多轮",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":"reply with exactly: MULTI-OK"}]}`, "MULTI-OK"},
|
||||
{"算术(验证真推理)",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"What is 17*23? Reply with just the number."}]}`, "391"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, err := vm.Transform("opencodezen", "transform_request", tc.body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(), "body": body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, _ := http.NewRequest("POST",
|
||||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||||
for k, v := range h {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
cl := &http.Client{Timeout: 90 * time.Second}
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
// 上游现在返回 SSE(适配器强制 stream=true);判据需自己拼 delta.content。
|
||||
got := ""
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
p := strings.TrimPrefix(line, "data: ")
|
||||
if p == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var d struct {
|
||||
Choices []struct {
|
||||
Delta struct{ Content string } `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if json.Unmarshal([]byte(p), &d) != nil || len(d.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
got += d.Choices[0].Delta.Content
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatalf("未从 SSE 解出内容;原始=%s", string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("回复 %q 不含 %q", got, tc.want)
|
||||
}
|
||||
t.Logf("✓ %s → %q", tc.name, strings.TrimSpace(got))
|
||||
})
|
||||
}
|
||||
fmt.Println("真上游 e2e 全部通过")
|
||||
}
|
||||
|
||||
// 经适配器完整转换链:上游 SSE → transform_stream_chunk → 统一格式。
|
||||
// 上一条判据直接读原始 SSE,只证明"上游放行";这条证明"适配器能解析"。
|
||||
func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) {
|
||||
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
|
||||
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启")
|
||||
}
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets",
|
||||
"-config", "/etc/llmsproxy/config.yaml").Output()
|
||||
apiKey := ""
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "source localzen") {
|
||||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := vm.Transform("opencodezen", "transform_request",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"reply with exactly: CHAIN-OK"}]}`)
|
||||
h, _ := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(), "body": body})
|
||||
|
||||
req, _ := http.NewRequest("POST",
|
||||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||||
for k, v := range h {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
|
||||
// 逐行喂给适配器的流解析器,累积统一格式的 content
|
||||
joined := ""
|
||||
n := 0
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
|
||||
continue
|
||||
}
|
||||
// 内核喂给适配器的是**已剥掉 data: 前缀**的载荷(见 provider.go:
|
||||
// strings.TrimPrefix(line, "data:")),判据必须对齐这个契约。
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
unified, err := vm.Transform("opencodezen", "transform_stream_chunk", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_stream_chunk: %v (%s)", err, line[:min(120, len(line))])
|
||||
}
|
||||
if unified == "" || unified == "{}" {
|
||||
continue
|
||||
}
|
||||
var u struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal([]byte(unified), &u) == nil {
|
||||
joined += u.Content
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("适配器未解出任何 chunk(流解析链断了)")
|
||||
}
|
||||
if !strings.Contains(joined, "CHAIN-OK") {
|
||||
t.Fatalf("经适配器链的内容 %q 不含 CHAIN-OK(chunk 数=%d)", joined, n)
|
||||
}
|
||||
t.Logf("✓ 完整链:%d 个 chunk,内容=%q", n, strings.TrimSpace(joined))
|
||||
}
|
||||
156
internal/lua/adapter_opencodezen_test.go
Normal file
156
internal/lua/adapter_opencodezen_test.go
Normal file
@ -0,0 +1,156 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// E2E:加载改后的适配器,验证 (1) 头含两个 session 头且值相同;(2) id 段服从公式;
|
||||
// (3) 无 tools 的请求被注入 read 桩且 properties 非空。
|
||||
func TestOpencodeZenAdapterE2E(t *testing.T) {
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatalf("加载失败: %v", err)
|
||||
}
|
||||
|
||||
t.Run("头:两个 session 头同值", func(t *testing.T) {
|
||||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{"timestamp": int64(1792065049)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1, s2 := h["x-opencode-session"], h["x-opencode-session-id"]
|
||||
if s1 == "" || s2 == "" {
|
||||
t.Fatalf("两个 session 头都必须存在: %#v", h)
|
||||
}
|
||||
if s1 != s2 {
|
||||
t.Fatalf("必须同值: %q vs %q", s1, s2)
|
||||
}
|
||||
if !strings.HasPrefix(s1, "ses_") || len(s1) != 4+12+14 {
|
||||
t.Fatalf("session 形状应为 ses_+12hex+14base62: %q (len=%d)", s1, len(s1))
|
||||
}
|
||||
if len(h["x-opencode-request"]) != 4+12+14 || !strings.HasPrefix(h["x-opencode-request"], "msg_") {
|
||||
t.Fatalf("request 形状: %q", h["x-opencode-request"])
|
||||
}
|
||||
if !strings.Contains(h["User-Agent"], "opencode/1.19.0") {
|
||||
t.Fatalf("UA 应为 1.19.0: %q", h["User-Agent"])
|
||||
}
|
||||
t.Logf("session=%s request=%s", s1, h["x-opencode-request"])
|
||||
// 公式校验:段 = (K - bucket_ms*4096) & MASK,其中 bucket 已对齐到整分钟
|
||||
// (适配器按分钟桶取会话号,以保留上游前缀缓存命中)。
|
||||
// ⚠️ 判据必须算**同一个桶**,不能拿原始 ms 去比 —— 那会把正确的实现判成错。
|
||||
seg := s1[4:16]
|
||||
ms := int64(1792065049) * 1000
|
||||
bucket := ms - (ms % 60000)
|
||||
want := (int64(0x1afffffffffffe) - bucket*4096) & 0xFFFFFFFFFFFF
|
||||
if got := seg; got != fmtHex12(want) {
|
||||
t.Fatalf("session 段公式不符: got=%s want=%s (bucket=%d)", got, fmtHex12(want), bucket)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools 兜底:无 tools 时注入 read", func(t *testing.T) {
|
||||
out, err := vm.Transform("opencodezen", "transform_request",
|
||||
`{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts, _ := req["tools"].([]interface{})
|
||||
if len(ts) == 0 {
|
||||
t.Fatalf("必须注入 tools: %s", out)
|
||||
}
|
||||
found := false
|
||||
for _, x := range ts {
|
||||
fn := x.(map[string]interface{})["function"].(map[string]interface{})
|
||||
if fn["name"] == "read" {
|
||||
p, _ := fn["parameters"].(map[string]interface{})
|
||||
props, _ := p["properties"].(map[string]interface{})
|
||||
if len(props) == 0 {
|
||||
t.Fatalf("read 的 properties 必须非空(门禁要求): %s", out)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("必须含名为 read 的工具: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools 保留:用户自带 read 不被覆盖", func(t *testing.T) {
|
||||
in := `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}],
|
||||
"tools":[{"type":"function","function":{"name":"read","description":"MY","parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}]}`
|
||||
out, err := vm.Transform("opencodezen", "transform_request", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal([]byte(out), &req)
|
||||
ts, _ := req["tools"].([]interface{})
|
||||
if len(ts) != 1 {
|
||||
t.Fatalf("不应追加(已有 read): %s", out)
|
||||
}
|
||||
fn := ts[0].(map[string]interface{})["function"].(map[string]interface{})
|
||||
if fn["description"] != "MY" {
|
||||
t.Fatalf("用户定义被覆盖: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func fmtHex12(v int64) string {
|
||||
const hex = "0123456789abcdef"
|
||||
out := make([]byte, 12)
|
||||
for i := 11; i >= 0; i-- {
|
||||
out[i] = hex[v&0xF]
|
||||
v >>= 4
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ★ 免费层只能流式(适配器强制 stream=true),而内核在客户端要非流式时
|
||||
// 会调 transform_response(期望 JSON)⇒ 适配器必须能把 SSE 聚合回非流式对象。
|
||||
// 这条判据钉住那个聚合:真实 SSE 片段 → 完整 chat.completion。
|
||||
func TestOpencodeZenAggregatesSSEForNonStreamClient(t *testing.T) {
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模仿上游真实 SSE(含 reasoning_content 与 usage 末帧)
|
||||
sse := `data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"think "}}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"HELLO"}}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":" WORLD"},"finish_reason":"stop"}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}}
|
||||
data: [DONE]
|
||||
`
|
||||
out, err := vm.Transform("opencodezen", "transform_response", sse)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var u map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &u); err != nil {
|
||||
t.Fatalf("聚合产物不是 JSON: %v (%s)", err, out)
|
||||
}
|
||||
if u["content"] != "HELLO WORLD" {
|
||||
t.Fatalf("content 应为 'HELLO WORLD',实得 %v", u["content"])
|
||||
}
|
||||
tu, _ := u["token_usage"].(map[string]interface{})
|
||||
if tu == nil || tu["prompt"] != float64(7) {
|
||||
t.Fatalf("usage 应透传: %v", u["token_usage"])
|
||||
}
|
||||
if u["finish_reason"] != "stop" {
|
||||
t.Fatalf("finish_reason 应为 stop: %v", u["finish_reason"])
|
||||
}
|
||||
t.Logf("聚合 OK: content=%v usage=%v finish=%v", u["content"], tu, u["finish_reason"])
|
||||
}
|
||||
451
internal/lua/adapter_prelude.go
Normal file
451
internal/lua/adapter_prelude.go
Normal 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
|
||||
}
|
||||
123
internal/lua/adapter_prelude_equivalence_test.go
Normal file
123
internal/lua/adapter_prelude_equivalence_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
128
internal/lua/adapter_prelude_test.go
Normal file
128
internal/lua/adapter_prelude_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
484
internal/lua/adapters/opencodezen.lua
Normal file
484
internal/lua/adapters/opencodezen.lua
Normal file
@ -0,0 +1,484 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "opencodezen"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
|
||||
-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1)
|
||||
--
|
||||
-- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的
|
||||
-- 服务,行为要求并不相同,因此各有专用适配器:
|
||||
--
|
||||
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
|
||||
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
|
||||
-- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传
|
||||
-- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。
|
||||
-- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content,
|
||||
-- 否则 thinking 模式直接 400。
|
||||
--
|
||||
-- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端,
|
||||
-- 部分模型在带 tools 时上游直接失败(流式返回单 chunk
|
||||
-- "finish_reason":"network_error" 空 content,非流式 503
|
||||
-- "Endpoint is unavailable",表现为"空回复")。
|
||||
-- ============================================================================
|
||||
-- ★ opencode Zen 免费层门禁(2026-10-15 实测解出,逐条对齐官方 CLI)
|
||||
--
|
||||
-- 上游对免费池有**三个**必须同时满足的条件,缺任一则 403 FreeTierError
|
||||
-- ("free tier can only be used from within OpenCode"),与 UA / TLS 指纹 / 凭据来源
|
||||
-- 均无关:
|
||||
--
|
||||
-- 1. 端点必须是 /inference/v1/chat/completions(不是 /zen/v1)
|
||||
-- —— 由源的 base_url 决定,不在本适配器内。
|
||||
-- 2. 必须同时发 x-opencode-session **与** x-opencode-session-id(值相同)。
|
||||
-- 两个 id 的 12 位 hex 段由**确定公式**给出(见下),不是随机数;
|
||||
-- 时间容差实测 ≥ 60 秒,故 os.time() 的秒精度足够。
|
||||
-- 3. 请求的 tools 里必须有一个名为 read 的工具,且其 parameters.properties
|
||||
-- **非空**。这是唯一的 tools 要求:实测抽掉官方 11 个工具中的任意一个均为
|
||||
-- 200,**只有抽掉 read 变 403**;且 properties 里放任意属性名即可,
|
||||
-- 描述文字 / $schema / required 都不需要(properties 为空对象则 403)。
|
||||
-- ⇒ 无 tools 的纯对话请求会被拒,故 transform_request 需兜底注入桩工具。
|
||||
-- 4. **stream 必须为 true**:实测同为合法请求,stream=false 恒 403、stream=true 恒 200
|
||||
-- (包括 max_tokens 20/60/200/1000/32000 均 200)。
|
||||
-- ⇒ 非流式请求会被拒,故 transform_request 强制 stream=true。
|
||||
--
|
||||
-- 明确**不影响**通过的(均实测):system 内容(含无 system)、消息条数
|
||||
-- (2/3/4/6 条均 200)、凭据(Bearer public 与普通 API key 均可)、
|
||||
-- x-opencode-project / x-opencode-client / UA 里的 opencode/ 前缀。
|
||||
--
|
||||
-- ⚠️ 参考实现 dsh-our-free-model 的门禁桩(bash + read,但无真实 properties)
|
||||
-- 恰好卡在第 3 条上 —— 即它「伪装需跟进上游更新」的真实原因。
|
||||
adapter.headers = {
|
||||
["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
|
||||
}
|
||||
|
||||
-- rand_id:按官方的 48-bit 公式生成 12 位 hex 段 + 14 位 base62 随机段。
|
||||
--
|
||||
-- 公式逆自官方 CLI 本地库(~/.local/share/opencode/opencode.db 的 session 表,
|
||||
-- 用 11/12 条真实 id 逐条比对完全一致):
|
||||
--
|
||||
-- MASK = 2^48 - 1
|
||||
-- K = 0x1afffffffffffe (注意不是 27<<48,两者差 2)
|
||||
-- session 段 = (K - now_ms * 4096) & MASK ← **倒计时式**:Δseg/Δms ≡ -4096
|
||||
-- request 段 = (now_ms * 16) & MASK
|
||||
--
|
||||
-- 为什么是「倒计时」而不是时间戳:0x1afffffffffffe 减去 ms*2^12,所以 seg 随
|
||||
-- 时间**递减**。用 f"{ms:012x}" 之类的正向写法必然不对(我为此错过一轮)。
|
||||
--
|
||||
-- ⚠️ 精度:now_ms*4096 ≈ 7.3e15 < 2^53 ≈ 9.0e15,在 double 安全整数范围内,
|
||||
-- 故 LuaJIT 的默认数字类型(double)直接算即可,无需大整数库。
|
||||
local B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
local MASK48 = 281474976710655 -- 2^48 - 1
|
||||
local ID_K = 0x1afffffffffffe
|
||||
|
||||
local function rand_tail(n)
|
||||
local out = {}
|
||||
for i = 1, n do
|
||||
local v = 0
|
||||
if math and math.random then
|
||||
v = math.random(1, 62)
|
||||
else
|
||||
-- 无 math 时用时间做退化熵源:只要唯一,不要求密码学强度。
|
||||
v = (os.time() * 7 + i * 13) % 62 + 1
|
||||
end
|
||||
out[i] = B62:sub(v, v)
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- now_ms:毫秒。沙箱只有 os.time()(秒)与 os.clock()(CPU 时间,无用),
|
||||
-- 而上游时间容差实测 ≥ 60 秒,故秒精度足够;毫秒位补 0。
|
||||
-- meta.timestamp 由内核给出(同为秒),优先用它以便同一请求内自洽。
|
||||
local function now_ms(meta)
|
||||
local t = tonumber(meta and meta.timestamp) or (os.time and os.time()) or 0
|
||||
return t * 1000
|
||||
end
|
||||
|
||||
local function rand_id(prefix, ms, kind)
|
||||
local seg
|
||||
if kind == "session" then
|
||||
seg = (ID_K - ms * 4096) % (MASK48 + 1)
|
||||
else
|
||||
seg = (ms * 16) % (MASK48 + 1)
|
||||
end
|
||||
-- %012x:12 位十六进制,不足补零
|
||||
return prefix .. string.format("%012x", seg) .. rand_tail(14)
|
||||
end
|
||||
|
||||
-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。
|
||||
--
|
||||
-- 为什么需要稳定:上游前缀缓存是会话级的(实测:同一段 6032 token 提示词,
|
||||
-- 固定会号第 2 次命中 5888;每请求换会号则恒为 0 命中)。而免费层门禁要求
|
||||
-- x-opencode-session 的 12 位 hex 段服从「倒计时」公式(见 rand_id 注释),
|
||||
-- 所以不能真的「随便挑一个固定值」,只能选一个**时间桶**。
|
||||
--
|
||||
-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中,
|
||||
-- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200),
|
||||
-- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。
|
||||
local function session_bucket_ms(meta)
|
||||
local ms = now_ms(meta)
|
||||
return ms - (ms % 60000)
|
||||
end
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local ms = now_ms(meta)
|
||||
local bucket = session_bucket_ms(meta)
|
||||
-- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。
|
||||
local sid = rand_id("ses_", bucket, "session")
|
||||
return {
|
||||
["User-Agent"] = adapter.headers["User-Agent"],
|
||||
["x-opencode-client"] = "cli",
|
||||
-- project 固定:同网关实例共享一个工作区身份(非门禁必需,但官方也发)
|
||||
["x-opencode-project"] = "global",
|
||||
["x-opencode-session"] = sid,
|
||||
-- ★ 两个都要:上游对免费池同时校验 x-opencode-session 与这个头。
|
||||
["x-opencode-session-id"] = sid,
|
||||
-- request id 每请求唯一(不参与缓存键),用当前时间而非桶,避免同桶内重复
|
||||
["x-opencode-request"] = rand_id("msg_", ms, "request"),
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- ★ 免费层 tools 兜底:上游要求 tools 里必须有一个名为 read、且
|
||||
-- parameters.properties 非空的工具(实测唯一要求;抽掉它即 403)。
|
||||
-- 纯对话请求(客户端没传 tools)会被拒,故这里注入一个桩工具。
|
||||
--
|
||||
-- 只补缺,不改用户已有的 tools:用户自带 read 时原样透传(官方 CLI 就是这么发的),
|
||||
-- 用户带的其它工具保留不动。
|
||||
local FREE_GATE_TOOL = "read"
|
||||
local function ensure_free_gate_tool(req)
|
||||
local tools = req.tools
|
||||
if type(tools) ~= "table" then tools = {} req.tools = tools end
|
||||
for _, t in ipairs(tools) do
|
||||
if type(t) == "table" then
|
||||
local fn = t["function"]
|
||||
-- 注意:function 是 Lua 关键字,必须用 req["function"] 形式取下标。
|
||||
if type(fn) == "table" and fn.name == FREE_GATE_TOOL then
|
||||
local p = fn.parameters
|
||||
-- 已有 read 但 properties 为空 ⇒ 补上;否则原样保留用户定义。
|
||||
if type(p) == "table" and type(p.properties) == "table"
|
||||
and next(p.properties) ~= nil then
|
||||
return
|
||||
end
|
||||
fn.parameters = { type = "object",
|
||||
properties = { filePath = { type = "string", description = "Path to read" } } }
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
tools[#tools + 1] = {
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = FREE_GATE_TOOL,
|
||||
description = "Read a file from the local filesystem.",
|
||||
parameters = { type = "object",
|
||||
properties = { filePath = { type = "string", description = "Path to read" } },
|
||||
required = { "filePath" } },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
||||
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
|
||||
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
|
||||
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
|
||||
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
|
||||
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
|
||||
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
|
||||
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
|
||||
local ROLE_WHITELIST = {
|
||||
system = true,
|
||||
user = true,
|
||||
assistant = true,
|
||||
tool = true,
|
||||
latest_reminder = true,
|
||||
}
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- ★ 免费层 gate:tools 里必须有 read(且 parameters.properties 非空)。
|
||||
-- 放在最前面:它决定本请求会不会被 403,与后面的字段清理无关。
|
||||
-- 对所有模型都注入:免费池与付费池共用同一层形状检查,多一个桩工具无害
|
||||
-- (官方 CLI 本来也带全套工具),但缺了它会让免费模型整体失效。
|
||||
ensure_free_gate_tool(req)
|
||||
-- ★ 免费层只接受流式:实测 stream=false 恒 403(同样 body 改 true 即 200)。
|
||||
-- 转成流式由上游以 SSE 回应,内核本就会聚合/透传流式响应,故对客户端透明。
|
||||
req.stream = true
|
||||
req.disable_thinking = nil
|
||||
req.extra_body = nil
|
||||
-- stream_options is only valid alongside stream:true; sending it on a
|
||||
-- non-streaming request is rejected by strict upstreams.
|
||||
if req.stream then
|
||||
if type(req.stream_options) ~= "table" then req.stream_options = {} end
|
||||
req.stream_options.include_usage = true
|
||||
end
|
||||
if req.messages then
|
||||
local kept = {}
|
||||
for _, msg in ipairs(req.messages) do
|
||||
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
|
||||
msg.role = "system"
|
||||
end
|
||||
-- Zen 免费池不接受回传 reasoning_content(Go 侧相反)
|
||||
msg.reasoning_content = nil
|
||||
local drop = false
|
||||
if type(msg.content) == "table" then
|
||||
local parts = {}
|
||||
for _, part in ipairs(msg.content) do
|
||||
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
|
||||
-- multimodal part not supported by zen
|
||||
else
|
||||
table.insert(parts, part)
|
||||
end
|
||||
end
|
||||
if #parts == 0 then
|
||||
-- Content collapsed to nothing after stripping unsupported
|
||||
-- parts. A message that still carries a tool call must
|
||||
-- NEVER be dropped: the very next message is its tool
|
||||
-- result, and dropping the call orphans that result. The
|
||||
-- model then sees a result for a call it never made and
|
||||
-- re-issues the same tool call on every turn (observed as
|
||||
-- an infinite "repeated tool call" loop).
|
||||
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
|
||||
or msg.tool_call_id ~= nil then
|
||||
msg.content = ""
|
||||
else
|
||||
drop = true
|
||||
end
|
||||
else
|
||||
msg.content = parts
|
||||
end
|
||||
end
|
||||
if not drop then
|
||||
table.insert(kept, msg)
|
||||
end
|
||||
end
|
||||
req.messages = kept
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- ★ aggregate_sse:把 SSE 流(openai chat.completion.chunk 形状)聚合成一个非流式
|
||||
-- 响应对象,供 transform_response 在「客户端要非流式、上游却回流式」时使用。
|
||||
-- 返回 JSON 字符串;无法聚合(无任何 chunk)时返回 nil。
|
||||
--
|
||||
-- 只处理 content / reasoning_content / finish_reason / usage 四类增量:
|
||||
-- 它们覆盖了内核需要从响应里取的全部字段(正文、结束原因、token 计数)。
|
||||
local function aggregate_sse(raw)
|
||||
local content, reasoning, finish = {}, {}, ""
|
||||
local usage, id, model, created = nil, nil, nil, nil
|
||||
local seen = false
|
||||
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if s:sub(1, 5) == "data:" then
|
||||
local payload = s:sub(6):gsub("^%s+", "")
|
||||
if payload ~= "" and payload ~= "[DONE]" then
|
||||
local ok, d = pcall(json.decode, payload)
|
||||
if ok and type(d) == "table" then
|
||||
seen = true
|
||||
id = id or d.id
|
||||
model = model or d.model
|
||||
created = created or d.created
|
||||
if type(d.usage) == "table" then usage = d.usage end
|
||||
local ch = d.choices and d.choices[1]
|
||||
if type(ch) == "table" then
|
||||
if ch.finish_reason and ch.finish_reason ~= "" then
|
||||
finish = ch.finish_reason
|
||||
end
|
||||
local dl = ch.delta
|
||||
if type(dl) == "table" then
|
||||
if type(dl.content) == "string" then content[#content + 1] = dl.content end
|
||||
if type(dl.reasoning_content) == "string" then
|
||||
reasoning[#reasoning + 1] = dl.reasoning_content
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not seen then return nil end
|
||||
local msg = { role = "assistant", content = table.concat(content) }
|
||||
local rc = table.concat(reasoning)
|
||||
if rc ~= "" then msg.reasoning_content = rc end
|
||||
local out = {
|
||||
id = id or "", object = "chat.completion", created = created or 0, model = model or "",
|
||||
choices = { { index = 0, message = msg, finish_reason = finish ~= "" and finish or "stop" } },
|
||||
}
|
||||
if usage then out.usage = usage end
|
||||
local ok2, enc = pcall(json.encode, out)
|
||||
if not ok2 then return nil end
|
||||
return enc
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- ★ 免费层只接受 stream=true(见文件头门禁第 4 条),所以本适配器把每个请求
|
||||
-- 都改成了流式 —— 但内核是按**客户端意图**选路径的:客户端发非流式时,
|
||||
-- 内核走 Provider.Chat 并调本函数(期望 JSON),而上游回的是 SSE。
|
||||
-- 若在这里直接 json.decode 就会得到 "invalid character 'd'" 错误。
|
||||
--
|
||||
-- 因此本函数必须先识别 SSE 并自行聚合,再走原来的 JSON 路径。
|
||||
-- 这样保持内核对上游协议无感(内核不知道也不该知道上游选了流式)。
|
||||
if type(raw_body) == "string" and (raw_body:sub(1, 6) == "data: " or raw_body:find("\ndata: ", 1, true)) then
|
||||
local aggregated = aggregate_sse(raw_body)
|
||||
if aggregated ~= nil then
|
||||
local ok2, resp2 = pcall(json.decode, aggregated)
|
||||
if ok2 and type(resp2) == "table" then raw_body = aggregated end
|
||||
end
|
||||
end
|
||||
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
|
||||
-- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断
|
||||
-- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。
|
||||
local ctd = resp.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
-- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。
|
||||
-- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。
|
||||
-- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。
|
||||
if resp.cost ~= nil then
|
||||
unified.cost = tostring(resp.cost)
|
||||
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 ""
|
||||
local reasoning = ch.message.reasoning_content or ch.message.reasoning
|
||||
if reasoning then
|
||||
unified.reasoning_content = reasoning
|
||||
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)
|
||||
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). Preserve it; the gateway emits it as the
|
||||
-- terminal usage chunk. Note: 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,
|
||||
}
|
||||
-- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif:
|
||||
-- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让
|
||||
-- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致
|
||||
-- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。
|
||||
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 }
|
||||
end
|
||||
if (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
|
||||
if uses.prompt_tokens_details == nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
local ctd = chunk.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
-- 空 choices 的帧既不是内容也不是结束信号。上游发两种:
|
||||
-- {"choices":[],"usage":{...}} —— 终帧用量
|
||||
-- {"choices":[],"cost":"0"} —— 单独的费用帧
|
||||
-- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。
|
||||
local out = { done = false }
|
||||
if uses ~= nil then out.usage = uses end
|
||||
if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end
|
||||
if uses == nil and chunk.cost == nil then return "" end
|
||||
return json.encode(out)
|
||||
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
|
||||
-- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content)
|
||||
local reasoning = delta.reasoning_content or delta.reasoning
|
||||
if reasoning then
|
||||
unified.reasoning_content = reasoning
|
||||
end
|
||||
if delta.tool_calls then
|
||||
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}};
|
||||
-- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。
|
||||
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 e.type == "FreeUsageLimitError" then
|
||||
return "zen free pool quota exhausted"
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -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
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
164
internal/lua/channels_gemini_adapter_test.go
Normal file
164
internal/lua/channels_gemini_adapter_test.go
Normal 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
|
||||
}
|
||||
102
internal/lua/channels_trae_solo_test.go
Normal file
102
internal/lua/channels_trae_solo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
74
internal/lua/outbound_grant_persist_test.go
Normal file
74
internal/lua/outbound_grant_persist_test.go
Normal 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 不该被放行")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
224
internal/lua/plugin_adapter_capability_test.go
Normal file
224
internal/lua/plugin_adapter_capability_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -100,6 +100,8 @@ type handlerRequest struct {
|
||||
|
||||
// handlerResponse is what a Lua handler returns.
|
||||
type handlerResponse struct {
|
||||
// Proxy is a loopback HTTP origin. The gateway owns streaming and upgrades.
|
||||
Proxy string
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body string
|
||||
@ -180,6 +182,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
return nil, fmt.Errorf("status must be a number, got %T", s)
|
||||
}
|
||||
}
|
||||
if target, ok := m["proxy"].(string); ok {
|
||||
out.Proxy = target
|
||||
}
|
||||
if b, ok := m["body"].(string); ok {
|
||||
out.Body = b
|
||||
}
|
||||
@ -196,9 +201,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
// return. A table carrying ONLY a status is not: `{status=204}` is the normal
|
||||
// way to answer with no body, and treating it as an error would make the
|
||||
// correct spelling unusable.
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 {
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && out.Proxy == "" && len(m) == 0 {
|
||||
return nil, fmt.Errorf("handler returned an empty table; return " +
|
||||
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
|
||||
"{status=…, body=…}, {status=…, json=…}, {status=…, proxy=…}, or {status=…} for no body")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 未被调用")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
@ -2339,6 +2359,8 @@ type HTTPHandler struct {
|
||||
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
|
||||
// segments, captured into request.params.
|
||||
Path string `json:"path"`
|
||||
// Prefix claims this path and its descendants for a streaming proxy handler.
|
||||
Prefix bool `json:"prefix,omitempty"`
|
||||
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
|
||||
// accepted — an arbitrary verb would need a switch in the router for no gain.
|
||||
Method string `json:"method,omitempty"`
|
||||
@ -2463,7 +2485,7 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
continue
|
||||
}
|
||||
ctx.strictKeys(h, "http[]", "path", "method", "handler", "description",
|
||||
"roles", "keys", "public")
|
||||
"roles", "keys", "public", "prefix")
|
||||
dh := HTTPHandler{
|
||||
Path: decString(h, "path"),
|
||||
Method: decString(h, "method"),
|
||||
@ -2484,6 +2506,9 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
}
|
||||
dh.Handler = name
|
||||
}
|
||||
if p, ok := h["prefix"].(bool); ok {
|
||||
dh.Prefix = p
|
||||
}
|
||||
if p, ok := h["public"].(bool); ok {
|
||||
dh.Public = p
|
||||
}
|
||||
|
||||
@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user