diff --git a/deploy.sh b/deploy.sh index b908167..07009a1 100755 --- a/deploy.sh +++ b/deploy.sh @@ -122,14 +122,14 @@ backup_old_state() { log "备份旧二进制到 $BACKUP_BIN" if [[ -f "$TARGET_BIN" ]]; then cp -f "$TARGET_BIN" "$BACKUP_BIN" - log "✓ 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))" + log "OK 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))" else warn "旧二进制不存在,跳过备份(无回滚点)" fi if [[ -f "$TARGET_CONFIG" ]]; then cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG" - log "✓ 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))" + log "OK 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))" else warn "线上配置不存在: $TARGET_CONFIG(首次部署?无配置回滚点)" fi @@ -175,9 +175,9 @@ build() { ) for c in "${checks[@]}"; do if strings "$NEW_BIN" | grep -qF "$c"; then - log " ✓ 新二进制包含: $c" + log " OK 新二进制包含: $c" else - warn " ⚠ 新二进制不包含: $c(非预期但非致命)" + warn " 注意: 新二进制不包含: $c(非预期但非致命)" fi done } @@ -207,7 +207,7 @@ atomic_replace_binary() { # 验证 NEW_SHA256=$(sha256sum "$TARGET_BIN" | awk '{print $1}') - log "✓ 二进制已原子替换 (sha256=${NEW_SHA256:0:16})" + log "OK 二进制已原子替换 (sha256=${NEW_SHA256:0:16})" } # ---------- 原子替换配置 ---------- @@ -223,7 +223,7 @@ atomic_replace_config() { log " rename → $TARGET_CONFIG (原子)" mv -f "$STAGING_CONFIG" "$TARGET_CONFIG" - log "✓ 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))" + log "OK 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))" } # ---------- 配置预检 ---------- @@ -236,7 +236,7 @@ verify_config() { local out if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then - log "✓ 配置合法" + log "OK 配置合法" return 0 fi @@ -276,7 +276,7 @@ verify_master_key() { log " 钥匙来源: LLMS_PROXY_MASTER_KEY(环境变量)" # 走真实解密路径验证,而不是只检查格式 if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then - log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)" + log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)" return 0 fi err "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:" @@ -292,7 +292,7 @@ verify_master_key() { log " 钥匙文件: $key_file ($(stat -c '%A' "$key_file" 2>/dev/null || echo '?'),$(wc -c < "$key_file") 字节)" if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then - log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)" + log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)" return 0 fi err "凭据无法解密(钥匙与配置不匹配),未替换任何文件:" @@ -388,7 +388,7 @@ sync_adapters() { grep -qE '\buses\b' "$TARGET_ADAPTERS/$f.lua" 2>/dev/null && ((usage_fixed++)) || true done log " usage 透传修复适配器: ${usage_fixed}/7 (openai/deepseek/github/groq/kimicode/mistral/opencode)" - log "✓ 适配器已同步到 $TARGET_ADAPTERS/" + log "OK 适配器已同步到 $TARGET_ADAPTERS/" } # 同步内置插件到 plugin_dir。 @@ -449,14 +449,14 @@ sync_plugins() { local name; name="$(basename "$src")" [[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name" done - log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)" + log "OK 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)" } # ---------- 重启服务 ---------- restart_service() { log "重启服务(中断 <500ms)" systemctl restart llmsproxy.service || fail "systemd restart 失败" - log "✓ llmsproxy.service restarted" + log "OK llmsproxy.service restarted" } # ---------- 健康检查 ---------- @@ -471,7 +471,7 @@ healthcheck() { local code code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$HEALTH_URL" 2>/dev/null || echo "000") if [[ "$code" == "200" || "$code" == "401" ]]; then - log "✓ 服务健康 (status=$code, ${attempt}/${attempts})" + log "OK 服务健康 (status=$code, ${attempt}/${attempts})" return 0 fi log " 等待中 ($attempt/$attempts, status=$code)" @@ -540,7 +540,7 @@ rollback() { local code code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "000") if [[ "$code" == "200" || "$code" == "401" ]]; then - log "✓ 回滚成功,旧版本已恢复服务 (status=$code)" + log "OK 回滚成功,旧版本已恢复服务 (status=$code)" log " 新版本部署失败,请检查构建/配置后重试" return 0 else diff --git a/docs/channels-parity.md b/docs/channels-parity.md new file mode 100644 index 0000000..16cbe6f --- /dev/null +++ b/docs/channels-parity.md @@ -0,0 +1,491 @@ +# channels 插件与参考插件的三线对齐表 + +参考仓:`Ebony-Vinyl/dsh-our-free-model`(本地克隆 +`/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model`),关键实现位于 +`vendor/channel-pack/src/`(144 个 TS 文件)。 + +命名规律(照它逐厂商对齐的三条线): + +| 后缀 | 职责 | 我们要对齐的东西 | +|---|---|---| +| `X-auth.ts` / `X-oauth.ts` / `login.ts` | **登录** | 登录流程、端点、参数、凭据形状 | +| `X-product.ts` / `models.ts` | **模型获取** | 远端端点、解析路径、兜底表 | +| `X-adapter.ts` / `llm-adapter.ts` | **接入模型服务** | chat 端点、载荷转换、鉴权头 | + +## 一、登录(line ①) + +| 平台 | 参考流程 | 我们的 flow | 状态 | +|---|---|---|---| +| trae | OAuth 17 参数 + 设备身份派生 | callback | ✅ 逐字节对齐(含 login_trace_id / machine_id / x_device_*) | +| minimax | device-poll | device-poll | ✅ | +| cline | device-poll | device-poll | ✅ | +| buddy | state-poll(**无回调**) | state-poll | ✅ | +| gemini | OAuth + client_secret | callback | ✅(含 client_secret、localhost 主机、6 scope) | +| lobsterai | 本地回调 + authCode 换 token | callback | ✅(含动态版本号、3 步活动领取) | +| opencode | paste | paste | ✅(参考本身即粘贴凭据) | +| **codearts** | **PKCE + DPoP ES256 + STS 换 AK/SK** | **callback** | ✅ **本轮修复**(此前误标 paste) | +| qoder | device-poll + 设备身份 | device-poll | ✅(含 Cosy-MachineToken/Type 成对) | +| zcode | init-poll | init-poll | ✅ | +| loomy | 微信扫码 poll | wechat-poll | ✅ | +| raccoon | 二维码 poll | qr-poll | ✅ | + +### codearts 登录(本轮修复的重点) + +此前标成 `flow="paste"`(手填凭据)是**错的**。参考 `login.ts` / `oauth.ts` +默认走**新式 IAM OAuth**: + +1. 起本地回调服务器,端口须 **≥10000**(`MIN_CALLBACK_PORT`,低于此 portal 拒绝), + 路径 `/oauth/callback` +2. 打开 `https://codearts.huaweicloud.com/portal/authorize`,参数逐项: + `theme=2`、`locale=zh-cn`、`uri_scheme=codearts-agent`、`client_id=codearts-agent`、 + `code_challenge_method=**SHA-256**`(不是 RFC 的 S256 —— 错值会让 portal + **回退旧 ticket 流程**,看起来"也能用"但不是 OAuth)、`ticket_id`、 + `plugin-name=snap_AIIDE`、`plugin-version=5.2.0`; + **不得**含 `auth_callback_url`(会被判异常并回退) +3. 回调收到 `code` 后,带 **DPoP 证明**向 + `https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens` 换 + `credentials{access_key_id, secret_access_key, security_token, expiration}` + + `refresh_token` +4. 凭据持久化须含续期材料:`refresh_token` / `code_verifier` / `dpop_private_key_jwk` + +DPoP 细节:ES256 JWS,`header={alg,typ:dpop+jwt,jwk}`、 +`payload={htm,htu,iat,jti}`;**签名是 raw r||s 64 字节**,而 openssl 输出 DER +——必须转换格式,否则服务端验签失败而字符串看起来完全正常。 + +终态判定只认 `invalid_grant` / `ErrorCode 含 ExpiredRefreshToken`; +**不认** `InvalidDPoPHeader`(那是一次请求层面的拒绝:时钟偏差、proof 重放、 +网关抖动都会触发,当终态会把完好账号一步标死)。 + +旧 ticket 流程(`CODEARTS_LOGIN_BASE` + `/snap-manager/v1/login/ticket` 轮询) +保留作回退。 + +## 二、模型获取(line ②) + +远端优先、兜底补位的机制已具备(`models_remote` + `effectiveModels`)。 +本轮把**兜底表**逐平台补成参考全量: + +| 平台 | 参考来源 | 兜底数 | 状态 | +|---|---|---|---| +| trae | `TRAE_FALLBACK_MODELS` | 28 可见 + 4 hidden | ✅(hidden 单列 `models_hidden`) | +| minimax | `GET /mavis/api/v1/models?region=cn&buildEnv=prod` | 4 | ✅ | +| cline | `GET /api/v1/models` + `recommended-models` | 3 | ✅ | +| buddy | (无独立表) | 7 | ✅ | +| gemini | — | 2 | ✅ | +| lobsterai | `GET /api/models/available`(**须带 model_flag**) | 19 | ✅ | +| opencode | `GET /v1/models` | 14 | ✅(含 `models_free`) | +| codearts | opengw `gateway/config` + `/v1/model/builtin` | 3 | ⚠️ 见下 | +| qoder | `/api/v2/model/list`(需 WASM 签名) | 19 | ✅(含 CN 独有 q37fmodel/gm51model) | +| zcode | `GET /api/v1/client/configs` | 2 | ✅ | +| loomy | `GET /api/v1/models`(取 `type==='chat'`) | 8 | ✅ | +| raccoon | 远端 + 兜底 | 6 | ✅ | + +**trae 的 `isHidden` 语义**:`browser_use_subagent` / `explore_sub_agent_v13` / +`explore_sub_agent_v2` / `summary` 是内部子 agent,不进用户可选列表。 +`model_list` 已按此过滤(`models_hidden`)。 + +**opencode 的 `isFree`**:参考明确标注 7 个模型匿名可用、其余需付费 key。 +此前 `model_list` 把 `isFree` 硬编码为 false,等于把官方免费模型标成付费。 + +### codearts 模型获取(未闭环) + +参考 `models.ts` 从两个端点拉: +- `OPENGW_GATEWAY_CONFIG_URL`(`opengw.developer.huaweicloud.com/api/v1/gateway/config`) + → `result.models`,这是 **benefit(免费额度)模型**权威来源 +- `SNAP_MODEL_BUILTIN_URL`(`/v1/model/builtin`)→ `builtinModels`,常规模型 + +两者都要 **AK/SK 的 SDK-HMAC-SHA256 签名**。另有 `normalizeModelId` +(去尾部 `-NNNN` 日期版本)与 `isCodeArtsBenefitModel`(决定 chat 是否带 +`maas_type: benefit`,不带则 404 `model is not registered`)。 +**这部分尚未实现**——我们的 codearts 目前只有 3 个硬编码模型名。 + +## 三、接入模型服务(line ③) + +**本轮发现的最严重问题**:源注册时 `base_url + adapter.endpoint` 拼出的 +chat URL **全部缺路径前缀**,11 个平台里 11 个都不对。已逐个修正: + +| 平台 | 正确 chat 端点 | 修正前 | +|---|---|---| +| trae | `…/api/agent/v3/llm_utils_chat` | `…/chat/completions` | +| cline | `api.cline.bot/api/v1/chat/completions` | `api.cline.bot/chat/completions` | +| lobsterai | `…/api/proxy/v1/chat/completions` | `…/chat/completions` | +| qoder | `api2-v2.qoder.sh/model/v1/chat/completions` | `…/chat/completions` | +| buddy | `copilot.tencent.com/v2/chat/completions` | `…/chat/completions` | +| codearts | `…/api/v2/chat/completions` | `…/chat/completions` | +| opencode | `opencode.ai/zen/v1/chat/completions` | `opencode.ai/zen/chat/completions` | +| raccoon | `…/api/web/llm/v2/chat/completions` | `…/chat/completions` | +| loomy | `loomy.xunfei.cn/chat/completions` | `loomy.xunfei.cn` | +| zcode | `zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages` | `zcode.z.ai/v1/messages` | +| gemini | `/v1internal:generateContent`(私有动词) | `/v1beta/models/{model}:generateContent`(公开,凭据不匹配) | + +### ✅ 已闭环:三个私有协议平台改用**插件自装适配器** + +这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主 +`LlmAdapter` 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。 +内核其实**早已实现** `__llmsproxy_adapters.install`(含归属校验/冲突仲裁/ +测试),但 channels 插件对该全局的引用数是 **0**。现已第一次真正使用: + +| 平台 | 自装适配器 | 解决的协议差异 | +|---|---|---| +| codearts | `codearts` | 华为云 SDK-HMAC-SHA256 **AK/SK 签名**(canonical/string-to-sign 逐字节) | +| trae | `channels-trae` | **SOLO 载荷**:config_name 双字段、function 通道、tool_calls→function_call、parameters→JSON 字符串、tool_choice 归一化 | +| gemini | `channels-gemini` | 私有 `/v1internal:` **信封** + 身份五头 + 字母序序列化 + 响应剥一层 | + +命名说明:trae 用 `channels-trae` 而非 `trae`,因为内置 `trae.lua` 服务的是 +**运维自建的 trae-local-api 反代**(`127.0.0.1:19900`,生产 config.yaml 在用), +与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)—— +判据里专门断言内置 `trae` 未被顶掉。 + +**新增能力:`credential_shape`**。签名平台需要**完整凭据 JSON**(适配器自己 +解 AK/SK),其余平台交裸 access token。此前 `syncAccountSource` 一律用 +`credentialOf` 取 `access_token`,于是 codearts 凭据(只有 +`access_key_id`/`secret_access_key`/`security_token`)取不到 token → +**静默 return、不出源**。现已按 `plat.credential_shape` 区分。 + +**另一处静默失败已修**:`syncAccountSource` 拿到内核 `add_source` 的失败 +(权限未授、形状不符)时不记日志,表现为"账号加了但不出源",排查零线索。 +现补失败日志(区分内核 err 与 `json.ok==false`)。 + +### 仍未闭环:gemini 的 sigstore(明确不做) + +参考 `gemini-sigstore.ts`(268 行)持久化 tool-call 签名,供跨请求回填。 +我们推理走**无状态适配器**,插件侧缓存会成为死代码。签名缺失时上游会拒, +适配器如实透传错误。理由与结论已记在此处,避免反复讨论。 + +## 四、明确不做的(附理由) + +| 参考模块 | 行数 | 为什么不做 | +|---|---|---| +| `gemini-sigstore.ts` | 268 | 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔ | +| `qoder-wasm.ts` | 1442 | 需随插件分发 298KB wasm 二进制;B 类,待立项 | +| `zcode-captcha.ts` | 2212 | 阿里云滑块,需真实浏览器/验证码链路;B 类 | +| `captcha-*.ts` | 1223 | 同上 | + +## 五、本机实测踩到的坑(都已修) + +1. **`xxd` 不存在** —— `command -v xxd` 为空,用它取十六进制会**静默得到空串**。 + 改用 `od -An -tx1`。 +2. **`openssl ec -text` 会截断 32 字节 priv**(实测只给 30 字节)→ `d` 必须用 + `openssl asn1parse` 的 `[HEX DUMP]`;`tail -c 32` 拿到的是 SEC1 结构里 + **公钥的 y**,不是 `d`。 +3. **Lua local 前向引用**:`local function` 只从定义处向后可见。`hexToBin` / + `b64url` / `b64urlRaw` 被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块 + **之前**(本文件累计踩过 6 次)。 +4. **`min_port` 参数读到 `argv[2]`** 与 `out_path` 冲突 —— 回调脚本的第 5 个 + 参数才是 minPort。 +5. **Lua 双引号串里的 `\1`** 是转义坑(`sed 's/…/…/\1/'` 会报 + invalid escape sequence)→ 改用 `awk -F': ' '{print $NF}'`。 + +--- + +# 为什么参考插件是"正常"的:架构差异(2026-10-13 查证) + +用户问「为什么他们就正常」。查完参考仓的架构后,答案是**三层结构差异**, +不是某一个端点的笔误。 + +## 参考的架构:一个平台 = 一个适配器类 + +参考依赖宿主 SDK 的 seam:`import { LlmAdapter } from '@deepseek-ai/dsh-llm'`。 +每个平台有一个**继承基类的真类**,同时拥有登录、模型目录、推理三件事: + +| 文件 | 类 | 干什么 | +|---|---|---| +| `trae-adapter.ts` | `TraeAdapter` | SOLO 协议转换(`config_name`/`function`,非 OpenAI 兼容) | +| `llm-adapter.ts` | `CodeArtsAdapter` | 华为云 AK/SK 签名 + `maas_type: benefit` 判定 | +| `zcode-adapter.ts` | `ZcodeAdapter` | Anthropic Messages 协议(非 OpenAI) | +| `qoder-adapter.ts` | `QoderAdapter` | WASM 加密端点 + 信封剥离 | +| `gemini-adapter.ts` | `GeminiAdapter` | `/v1internal:` 私有动词 + 字母序信封 | + +注册方式是 `registerAdapterIdempotent(ctx.llm, [PROVIDER], adapter)` +(`llm-adapter.ts:2080`)。 + +**但它不重复**:共享层是 `src/openai-compat.ts`(1196 行)—— +`serializeMessages`(消息序列化)、`consumeOpenAiSse`(SSE 消费)、 +`httpErrorCode` / `isTransportError`(错误归类)、`userContentParts`(多模态)。 +12 个 adapter 里有 8 个 `import from './openai-compat'`。 + +即:**公共协议逻辑一份,平台差异各自一个类**。 + +## 我们的架构:内核是 proxy,插件只注册源 + +- 适配器是 Lua 文件(12 个),通过 4 个钩子参与: + `transform_request` / `transform_response` / `transform_stream_chunk` / + `transform_error`,外加 `build_headers`(动态签名,见下)。 +- 声明面:`endpoint` / `endpoint_stream` / `ingress` / `headers`。 +- 插件通过 `__llmsproxy_kernel.call("add_source", {source={...}})` 把平台 + 注册为上游源,`source.adapter` 引用适配器名。 + +## 关键发现:机制早就有,但 channels 插件从没用过 + +内核**完整实现了**插件自装适配器: + +``` +internal/lua/plugin_adapter_bridge.go + func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error) + registerAdapterBridge → Lua 全局表 __llmsproxy_adapters.install / .update / .remove +``` + +- 有归属校验(`adapterOwnership`:只能改自己装的) +- 有冲突仲裁(影子内置/他人所有 → 挂起等运维裁决,不是硬拒) +- 有测试证明可用:`TestPluginInstallsAdapterFromLua`(PASS) + +**但 `grep -c '__llmsproxy_adapters' internal/lua/plugins/channels.lua` = 0。** +channels 插件一个适配器都没装过 —— 它只会注册 `adapter = "openai"` 的源。 +这就是"为什么我们不正常"的直接原因:**平台协议差异被当成适配器名差异**。 + +## 我们还缺的一层:共享协议模块 + +参考有 `openai-compat.ts`;我们没有对应物。`openai.lua` 自己写明 +「Lua adapters have no shared prelude」,`safe_tool_id` 在 `openai.lua` 与 +`anthropic.lua` 里**各复制了一份**(各 6 处引用)。 + +所以正确做法是两层: +1. 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类), + 或内核在加载时注入 —— 消掉复制粘贴 +2. channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、 + codearts AK/SK 签名)**装自己的适配器**,而不是硬塞给 `openai` + +### 所需的适配器能力(crypto + 出站 + 流式) + +已核实内核提供: +- `sha256_hex` / `hmac_sha256_hex`(`vm.go` setupGlobals) +- `build_headers(meta)` 收 `{url, method, body, api_key, timestamp, + client_session, source{name,meta}}` —— CodeArts 的 AK/SK 签名用它 + (与参考 `signRequestHuawei` 同构,但 AK/SK 要从 `api_key` 里解出) +- `transform_stream_chunk` 逐 chunk 调用(`provider.go:1295`) +- 插件有完整 OS 能力(`io.popen`/`os.execute`)→ 可用 openssl 做 EC 签名 + +### 未验证的风险 + +- 适配器 VM 能否访问 `__llmsproxy_secrets`(AK/SK 要解出 SK 才能签名) +- 流式转换能否跨 chunk 保持状态(trae 的 SSE 需要跨帧累积 tool_calls) + +这两条是下一步落地前必须先探的。 + +--- + +# 插件自装适配器:机制与判据(2026-10-13) + +## 机制(内核早已具备,本轮才开始使用) + +``` +internal/lua/plugin_adapter_bridge.go + InstallAdapter(plugin, name, code) # 含归属校验、冲突仲裁(影子内置 → 挂起等裁决) + registerAdapterBridge → Lua 全局 __llmsproxy_adapters.install / .update / .remove +``` +注册点在 `bootPluginState`,**先于** `DoString(code)` —— 所以插件在脚本执行期 +就能装适配器。适配器装在**适配器 VM**(与插件 VM 分离,各自独立)。 + +## 三条能力(实测,不是推断) + +`internal/lua/plugin_adapter_capability_test.go` 锁死: + +| 能力 | 结论 | 为什么关键 | +|---|---|---| +| 拿凭据 | 适配器**能**经 `build_headers` 的 `meta.api_key` 拿到(`url`/`method`/`body` 也在) | AK/SK 签名的前提;同时**验不到** `__llmsproxy_secrets`(只注入插件 VM,安全边界完好) | +| 流式状态 | `transform_stream_chunk` **跨 chunk 保持同一 Lua 状态**(表地址恒定) | SOLO 跨帧累积 tool_calls、gemini 跨帧剥信封的前提 | +| 加密原语 | 适配器可用内核 `sha256_hex` / `hmac_sha256_hex`(用标准向量校验) | 签名不需要外部进程 | + +## 踩过的坑(都是"静默失败"类,值得记) + +1. **`function` 是 Lua 关键字** —— 写 `body.function = x` 是**语法错误** + (`'' expected near 'function'`),必须 `body["function"]`。 + 插件脚本本身能编译通过,**只在适配器 `load()` 时才暴露**。 +2. **Lua 长字符串里不能有字面 NUL 字节** —— 报 + `unfinished long string near ''`,且**报在文件末尾**,完全指不到现场。 +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 特性或前提不存在的模块。 diff --git a/docs/plugins.md b/docs/plugins.md index dce8052..deaf260 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -46,7 +46,7 @@ plugin.ui = { page = { page_id = "hello", -- kebab-case title = "Hello", - icon = "👋", + icon = "", order = 90, -- 侧栏排序 mount = [[
hello
]], }, @@ -211,7 +211,7 @@ plugin.ui = { page = { page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing title = "Billing", -- 必填,侧栏文字 - icon = "💰", -- 可选 + icon = "", -- 可选 order = 40, -- 侧栏排序,默认 100 mount = [[...HTML...]], }, @@ -428,7 +428,7 @@ end 因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**) 都用这同一把互斥锁串行化。 -⚠️ 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的 +注意: 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的 `plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了, 对实际流量完全无效。 @@ -449,7 +449,7 @@ end 与适配器相同:`json.encode` / `json.decode`、`log(level, msg)`、 `hmac_sha256_hex` / `sha256_hex` / `base64_encode` / `tohex`。 -⚠️ `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。 +注意: `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。 ### 6.4 命名 @@ -536,7 +536,7 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \ { "prices": { ... }, "count_failures": true } ``` -### 7.5 ⚠️ 计费插件只报表,不执法 +### 7.5 注意: 计费插件只报表,不执法 **网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。** 本插件不参与任何路由或配额决策。 diff --git a/e2e/channels_flows_e2e_test.go b/e2e/channels_flows_e2e_test.go index 8fd4458..f7597c7 100644 --- a/e2e/channels_flows_e2e_test.go +++ b/e2e/channels_flows_e2e_test.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) } } diff --git a/internal/billing/wildcard_test.go b/internal/billing/wildcard_test.go index 246b47b..512bba6 100644 --- a/internal/billing/wildcard_test.go +++ b/internal/billing/wildcard_test.go @@ -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) } } diff --git a/internal/core/channels_account_adopt_test.go b/internal/core/channels_account_adopt_test.go new file mode 100644 index 0000000..5ce780f --- /dev/null +++ b/internal/core/channels_account_adopt_test.go @@ -0,0 +1,102 @@ +package core + +import ( + "testing" +) + +// account_adopt:把升级前的**非池**凭据接管进账号池。 +// +// 为什么需要它:v2.0.x 之前登录只写一条 `channels:`,引入账号池后凭据落在 +// `channels::`。升级后老凭据仍在保险库、但池里没有账号引用它 —— +// 表现为"账号消失了"(数据其实还在)。让用户重新登录是**可见的额外代价**。 +// +// ⚠️ 本判据同时钉住一条操作纪律:**接管必须走插件的保险库 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 +} diff --git a/internal/core/channels_account_probe_test.go b/internal/core/channels_account_probe_test.go new file mode 100644 index 0000000..8289121 --- /dev/null +++ b/internal/core/channels_account_probe_test.go @@ -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"]) + } +} diff --git a/internal/core/channels_authorize_test.go b/internal/core/channels_authorize_test.go new file mode 100644 index 0000000..5b7d1e6 --- /dev/null +++ b/internal/core/channels_authorize_test.go @@ -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) + } + } + }) +} diff --git a/internal/core/channels_autoclaim_test.go b/internal/core/channels_autoclaim_test.go new file mode 100644 index 0000000..e3168b1 --- /dev/null +++ b/internal/core/channels_autoclaim_test.go @@ -0,0 +1,150 @@ +package core + +import ( + "strings" + "testing" +) + +// 自动签到的**能力表**必须与参考真值一致 —— 这张表是 auto_claim 的唯一依据, +// 错了就是"静默少领"或"每次调用都报自相矛盾的错"。 +// +// 实测踩到的三处偏差(本轮修正): +// +// buddy 我们写 claim=true,但 buddy 既无 checkin 又无 claim_fn → +// 每次调用返回"不支持每日签到(平台未声明 checkin)"(表说支持、 +// 函数说不支持)。参考里 buddy **没有** claim。 +// raccoon 我们声明空表 → 白丢一个可领渠道。参考有:桌面端登录奖励 +// POST /api/web/desktop/v1/login/points/grant(每号一次)。 +// zcode 有签到,但每次领取现场产阿里云 captcha(headful Chromium, +// 按设备限流 150 次/小时)→ 参考把它列进 auto-checkin 的**排除表**。 +// 自动跳过,手动仍可领。 +func TestAutoClaimCapabilityTableMatchesReference(t *testing.T) { + c, ps := poolCore(t) + _ = c + + // 参考真值(逐个 grep 参考仓的 claim 导出确认) + wantClaim := map[string]bool{ + "trae": true, "minimax": true, "lobsterai": true, "qoder": true, + "loomy": true, "raccoon": true, "codearts": true, + "cline": false, "gemini": false, "zcode": true, "buddy": false, + } + // 自动签到必须跳过的(领取有代价) + wantAutoExclude := map[string]bool{"zcode": true} + + for plat, want := range wantClaim { + t.Run("claim_"+plat, func(t *testing.T) { + got := creditsCapability(t, ps, plat) + if got == nil { + t.Fatalf("%s 无能力声明", plat) + } + if has := got["claim"] == true; has != want { + t.Errorf("%s claim = %v,参考为 %v", plat, has, want) + } + if got, ok := got["auto_exclude"].(bool); ok != wantAutoExclude[plat] { + t.Errorf("%s auto_exclude = %v,应为 %v", plat, got, wantAutoExclude[plat]) + } + }) + } + + // 关键一致性:声称 claim=true 的平台**必须**真的可领 + //(有 checkin 表或有 claim_fn)—— 否则就是 buddy 那种自相矛盾。 + for plat, want := range wantClaim { + if !want { + continue + } + m, err := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": plat}) + if err != nil { + t.Fatalf("%s: %v", plat, err) + } + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j == nil { + t.Fatalf("%s 无返回", plat) + } + if j["capable"] != true { + t.Errorf("%s 声称 claim=true 但不可领(%v)—— 会出现「表说支持、"+ + "函数说不支持」的自相矛盾", plat, j["reason"]) + } + } + // 反向:不声称 claim 的平台必须被判为不可领 + m, _ := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": "buddy"}) + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j != nil && j["capable"] == true { + t.Error("buddy 不该被判为可领(它没有 claim)") + } +} + +// 自动签到的**调度配置**必须覆盖两项需求:领取 + 模型获取。 +// +// auto_claim 走调度器(不是 HTTP export),故断言调度声明。 +func TestAutoClaimScheduledWithModelRefresh(t *testing.T) { + b, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + for _, want := range []struct{ name, needle string }{ + {"每日领取", `fn = "auto_claim"`}, + {"模型目录自动获取", `fn = "refresh_models"`}, + {"凭据自动续期", `fn = "refresh_creds"`}, + {"启动即补跑", `first_due_now = true`}, + } { + if !strings.Contains(b, want.needle) { + t.Errorf("调度表缺 %s(%s)", want.name, want.needle) + } + } + // 模型刷新间隔照参考 MODEL_REFRESH_INTERVAL_MS(2 小时) + if !strings.Contains(b, `fn = "refresh_models", every = 7200`) { + t.Error("refresh_models 间隔应为 7200(参考 2h)") + } + // 按**行**判断(固定字符窗会溢到下一行,误报) + lineOf := func(fn string) string { + for _, ln := range strings.Split(b, "\n") { + if strings.Contains(ln, `fn = "`+fn+`"`) { + return ln + } + } + return "" + } + // auto_claim 是每日任务,**不该** first_due_now(重启就重领是错的) + if ln := lineOf("auto_claim"); strings.Contains(ln, "first_due_now") { + t.Errorf("auto_claim 不该 first_due_now(每日任务重启后重领是错的):%s", ln) + } + // 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈) + for _, fn := range []string{"refresh_models", "refresh_creds"} { + ln := lineOf(fn) + if ln == "" { + continue + } + if !strings.Contains(ln, "first_due_now = true") { + t.Errorf("%s 应声明 first_due_now(参考点名的重启自愈缺陷):%s", fn, ln) + } + } + // auto_exclude 必须挡住 zcode(领取有代价:headful Chromium) + if !strings.Contains(b, "auto_exclude = true") { + t.Error("zcode 应标记 auto_exclude(照参考 isAutoCheckinExcluded)") + } + if i := strings.Index(b, `if cap.auto_exclude then`); i < 0 { + t.Error("auto_claim 必须尊重 auto_exclude(否则会拉起浏览器)") + } +} + +// creditsCapability 读插件的能力表条目。 +func creditsCapability(t *testing.T, ps interface { + Call(string, string, map[string]interface{}) (interface{}, error) +}, plat string) map[string]interface{} { + t.Helper() + m, err := ps.Call("channels", "credits_capability", map[string]interface{}{"platform": plat}) + if err != nil { + t.Fatalf("credits_capability(%s): %v", plat, err) + } + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j == nil { + return nil + } + if cap, ok := j["capability"].(map[string]interface{}); ok { + return cap + } + return nil +} diff --git a/internal/core/channels_balance_tier_test.go b/internal/core/channels_balance_tier_test.go new file mode 100644 index 0000000..25bf422 --- /dev/null +++ b/internal/core/channels_balance_tier_test.go @@ -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() } diff --git a/internal/core/channels_callback_loopback_test.go b/internal/core/channels_callback_loopback_test.go new file mode 100644 index 0000000..ba43d20 --- /dev/null +++ b/internal/core/channels_callback_loopback_test.go @@ -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) + } + } + } +} diff --git a/internal/core/channels_capability_parse_test.go b/internal/core/channels_capability_parse_test.go new file mode 100644 index 0000000..eee1342 --- /dev/null +++ b/internal/core/channels_capability_parse_test.go @@ -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 路径可能错了") + } +} diff --git a/internal/core/channels_capability_test.go b/internal/core/channels_capability_test.go new file mode 100644 index 0000000..1753329 --- /dev/null +++ b/internal/core/channels_capability_test.go @@ -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") + } +} diff --git a/internal/core/channels_codearts_adapter_test.go b/internal/core/channels_codearts_adapter_test.go new file mode 100644 index 0000000..c234623 --- /dev/null +++ b/internal/core/channels_codearts_adapter_test.go @@ -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) + } +} diff --git a/internal/core/channels_codearts_oauth_flow_test.go b/internal/core/channels_codearts_oauth_flow_test.go new file mode 100644 index 0000000..5ac1257 --- /dev/null +++ b/internal/core/channels_codearts_oauth_flow_test.go @@ -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", + }) +} diff --git a/internal/core/channels_codearts_oauth_test.go b/internal/core/channels_codearts_oauth_test.go new file mode 100644 index 0000000..e8172f1 --- /dev/null +++ b/internal/core/channels_codearts_oauth_test.go @@ -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") + } +} diff --git a/internal/core/channels_codearts_port_test.go b/internal/core/channels_codearts_port_test.go new file mode 100644 index 0000000..23d49b8 --- /dev/null +++ b/internal/core/channels_codearts_port_test.go @@ -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 +} diff --git a/internal/core/channels_dead_model_test.go b/internal/core/channels_dead_model_test.go new file mode 100644 index 0000000..d435721 --- /dev/null +++ b/internal/core/channels_dead_model_test.go @@ -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 +} diff --git a/internal/core/channels_gate_test.go b/internal/core/channels_gate_test.go new file mode 100644 index 0000000..401419a --- /dev/null +++ b/internal/core/channels_gate_test.go @@ -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 +} diff --git a/internal/core/channels_lobster_claim_test.go b/internal/core/channels_lobster_claim_test.go new file mode 100644 index 0000000..45d7acb --- /dev/null +++ b/internal/core/channels_lobster_claim_test.go @@ -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 与幂等键)") + } + }) + } +} diff --git a/internal/core/channels_lobster_exchange_test.go b/internal/core/channels_lobster_exchange_test.go new file mode 100644 index 0000000..26fe322 --- /dev/null +++ b/internal/core/channels_lobster_exchange_test.go @@ -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) + } +} diff --git a/internal/core/channels_lobster_loopback_test.go b/internal/core/channels_lobster_loopback_test.go new file mode 100644 index 0000000..a86db57 --- /dev/null +++ b/internal/core/channels_lobster_loopback_test.go @@ -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 +} diff --git a/internal/core/channels_loomy_test.go b/internal/core/channels_loomy_test.go index f218e06..7ee15be 100644 --- a/internal/core/channels_loomy_test.go +++ b/internal/core/channels_loomy_test.go @@ -128,7 +128,7 @@ func TestLoomyWechatPollBehavior(t *testing.T) { // 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里) st := h.pollOnce(sid) if st["state"] != "pending" { - t.Fatalf("★ 404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"]) + t.Fatalf("404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"]) } }) diff --git a/internal/core/channels_models_remote_test.go b/internal/core/channels_models_remote_test.go new file mode 100644 index 0000000..c4c560a --- /dev/null +++ b/internal/core/channels_models_remote_test.go @@ -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),而非编造的占位名") + } +} diff --git a/internal/core/channels_plugin_test.go b/internal/core/channels_plugin_test.go index 690eff6..3100b51 100644 --- a/internal/core/channels_plugin_test.go +++ b/internal/core/channels_plugin_test.go @@ -92,7 +92,7 @@ func TestChannelsPluginEndToEnd(t *testing.T) { } // 且不得出现在 state 里 if stateHasSecret(ps, "channels", "tok-1") { - t.Fatal("★ 凭据泄漏进了 plugin.state") + t.Fatal("凭据泄漏进了 plugin.state") } // 5. 运维授权后,重新接入应当真的把源注册进内核 diff --git a/internal/core/channels_pool_test.go b/internal/core/channels_pool_test.go index a7b61bd..01b0964 100644 --- a/internal/core/channels_pool_test.go +++ b/internal/core/channels_pool_test.go @@ -124,13 +124,18 @@ func TestChannelsAccountPoolBehavior(t *testing.T) { if !ok { t.Fatal("带凭据账号应已同步源") } - if n := len(asArr(sm["models"])); n != 3 { - t.Fatalf("源应有3模型, 实得 %d", n) + // 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐 + // 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**: + // 关掉一个模型,源里就少一个。 + all := invoke(t, ps, "model_list", `{"platform":"minimax"}`) + total := len(asArr(all["models"])) + if n := len(asArr(sm["models"])); n != total { + t.Fatalf("源应含全部 %d 个模型, 实得 %d", total, n) } invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid) - if n := len(asArr(sm["models"])); n != 2 { - t.Fatalf("关1模型后源应2模型, 实得 %d", n) + if n := len(asArr(sm["models"])); n != total-1 { + t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n) } invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`) m = invoke(t, ps, "provider_status", `{}`) @@ -260,17 +265,33 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) { invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`) }) - t.Run("model_clear_dead_只清黑名单里已下线的", func(t *testing.T) { - invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) + // model_clear_dead 的语义已按参考修正:它清的是**有阳性 404 证据**的 + // "已失效模型"记录,**不是**"被禁用且不在目录里"的条目。 + // + // 旧断言测的是后者(拿远端目录反推下架)—— 那个判据在远端目录残缺的平台上 + // 会删掉可用模型(参考文件头有实测教训),已弃用。 + // 新语义的完整判据在 channels_dead_model_test.go。 + t.Run("model_clear_dead_清失效记录而非拿目录反推", func(t *testing.T) { + // 没有失效记录时:removed=0,且文案要说明"无记录"(而不是假装清了什么) j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) - if n := len(asArr(j["removed"])); n != 0 { - t.Fatalf("在册模型不算死模型, 实得 %d", n) + if j["ok"] != true { + t.Fatalf("model_clear_dead 应成功: %v", j) } - // 手动放一个不在 models 表里的黑名单项 - invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["ghost-model"],"disabled":true}`) - j = invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) - if n := len(asArr(j["removed"])); n != 1 { - t.Fatalf("死模型应被清: %v", j) + if n, _ := j["removed"].(float64); n != 0 { + t.Fatalf("无失效记录时应为 0, 实得 %v", j["removed"]) + } + // 黑名单(disabled)与失效记录是两回事:清失效**不得**动 disabled + invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) + invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) + ml := invoke(t, ps, "model_list", `{"platform":"minimax"}`) + off := 0 + for _, m := range asArr(ml["models"]) { + if mm, ok := m.(map[string]interface{}); ok && mm["disabled"] == true { + off++ + } + } + if off != 1 { + t.Fatalf("清失效记录不该动 disabled 黑名单, 仍应为 1, 实得 %d", off) } invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`) }) diff --git a/internal/core/channels_private_adapter_wiring_test.go b/internal/core/channels_private_adapter_wiring_test.go new file mode 100644 index 0000000..a079fc2 --- /dev/null +++ b/internal/core/channels_private_adapter_wiring_test.go @@ -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"]) + } + }) + } +} diff --git a/internal/core/channels_qoder_claim_test.go b/internal/core/channels_qoder_claim_test.go new file mode 100644 index 0000000..d35b1f8 --- /dev/null +++ b/internal/core/channels_qoder_claim_test.go @@ -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"]) + } + }) + } +} diff --git a/internal/core/channels_qoder_machine_test.go b/internal/core/channels_qoder_machine_test.go new file mode 100644 index 0000000..03d1166 --- /dev/null +++ b/internal/core/channels_qoder_machine_test.go @@ -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"]) + } + }) +} diff --git a/internal/core/channels_qoder_test.go b/internal/core/channels_qoder_test.go index bd1584e..3b68f70 100644 --- a/internal/core/channels_qoder_test.go +++ b/internal/core/channels_qoder_test.go @@ -175,7 +175,7 @@ func TestQoderDevicePollBehavior(t *testing.T) { t.Fatalf("池路径应完成: %v", st) } if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok { - t.Fatalf("★ 池路径凭据未落到 channels:qoder:%s", aid) + t.Fatalf("池路径凭据未落到 channels:qoder:%s", aid) } }) } diff --git a/internal/core/channels_raccoon_test.go b/internal/core/channels_raccoon_test.go index 1cdb29a..198d45d 100644 --- a/internal/core/channels_raccoon_test.go +++ b/internal/core/channels_raccoon_test.go @@ -159,7 +159,7 @@ func TestRaccoonQrPollBehavior(t *testing.T) { _, sid := hh.begin() st := hh.pollOnce(sid) if st["state"] != "pending" { - t.Fatalf("★ 缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"]) + t.Fatalf("缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"]) } }) } diff --git a/internal/core/channels_refresh_test.go b/internal/core/channels_refresh_test.go new file mode 100644 index 0000000..c605050 --- /dev/null +++ b/internal/core/channels_refresh_test.go @@ -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) + } +} diff --git a/internal/core/channels_store_test.go b/internal/core/channels_store_test.go index c428065..057f20a 100644 --- a/internal/core/channels_store_test.go +++ b/internal/core/channels_store_test.go @@ -53,7 +53,7 @@ func TestChannelsStoreIsPluginOwned(t *testing.T) { storePath := filepath.Join(dir, ".channels-store.json") b, err := os.ReadFile(storePath) if err != nil { - t.Fatalf("★ 插件自有存储未写出(%s): %v\n"+ + t.Fatalf("插件自有存储未写出(%s): %v\n"+ " —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err) } var saved struct { @@ -94,7 +94,7 @@ func TestChannelsStoreIsPluginOwned(t *testing.T) { } } if !found { - t.Fatalf("★ 重启后账号 %s 未恢复 —— 持久化未生效", aid) + t.Fatalf("重启后账号 %s 未恢复 —— 持久化未生效", aid) } } @@ -155,7 +155,7 @@ func TestChannelsMigratesLegacyKernelState(t *testing.T) { } } if !found { - t.Fatal("★ 遗留账号未迁移 —— 升级即丢账号") + t.Fatal("遗留账号未迁移 —— 升级即丢账号") } // ② 自有存储必须已写出 @@ -164,7 +164,7 @@ func TestChannelsMigratesLegacyKernelState(t *testing.T) { } // ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储) if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { - t.Fatal("★ 遗留内核文件应被删除,否则下次启动会被它覆盖") + t.Fatal("遗留内核文件应被删除,否则下次启动会被它覆盖") } } @@ -213,7 +213,7 @@ func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) { // 写失败的态下,遗留文件必须原样保留 if _, err := os.Stat(legacyPath); err != nil { - t.Fatalf("★ 写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err) + t.Fatalf("写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err) } // 且自有存储不应存在(写失败了) if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil { diff --git a/internal/core/channels_stress_test.go b/internal/core/channels_stress_test.go index 8f32ab6..7f9f9eb 100644 --- a/internal/core/channels_stress_test.go +++ b/internal/core/channels_stress_test.go @@ -204,7 +204,7 @@ func TestLegacyVaultPlaintextLoad(t *testing.T) { t.Fatal(err) } if strings.Contains(string(raw), "PLAIN-OLD-TOKEN") { - t.Fatalf("★ 重封后旧明文仍在盘上:\n%s", raw) + t.Fatalf("重封后旧明文仍在盘上:\n%s", raw) } if !strings.Contains(string(raw), "enc:v1:") { t.Fatalf("重封后应含密文:\n%s", raw) @@ -252,7 +252,7 @@ func TestLegacySourceSealedConfig(t *testing.T) { t.Fatal(err) } if strings.Contains(string(raw), "PLAIN-LEGACY-KEY") { - t.Fatalf("★ 旧明文 api_key 升级后仍明文落盘:\n%s", raw) + t.Fatalf("旧明文 api_key 升级后仍明文落盘:\n%s", raw) } if !strings.Contains(string(raw), "enc:v1:") { t.Fatalf("落盘应含 enc:v1 密文:\n%s", raw) diff --git a/internal/core/channels_trae_checkin_test.go b/internal/core/channels_trae_checkin_test.go new file mode 100644 index 0000000..4bf1a1d --- /dev/null +++ b/internal/core/channels_trae_checkin_test.go @@ -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 (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") + } + }) +} diff --git a/internal/core/channels_trae_derive_test.go b/internal/core/channels_trae_derive_test.go new file mode 100644 index 0000000..e8527e6 --- /dev/null +++ b/internal/core/channels_trae_derive_test.go @@ -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{} +} diff --git a/internal/core/channels_trae_test.go b/internal/core/channels_trae_test.go index c27f687..6b6e895 100644 --- a/internal/core/channels_trae_test.go +++ b/internal/core/channels_trae_test.go @@ -1,6 +1,7 @@ package core import ( + "encoding/json" "fmt" "io" "net/http" @@ -115,10 +116,26 @@ func TestTraeCallbackBehavior(t *testing.T) { if st != 200 { t.Fatalf("应 200: %d %s", st, body) } - // 凭据必须是 ExchangeToken 的新 Token,不是回调原值 + // 凭据必须是 ExchangeToken 的新 Token,不是回调原值。 + // 凭据是 JSON(Trae 需 device_id/uid 供签到派生设备身份),故解出 access_token。 v, _, _ := h.ps.GetSecret("channels", "channels:trae") - if !strings.HasPrefix(v, "ACCESS-") { - t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", v) + var cred map[string]interface{} + if err := json.Unmarshal([]byte(v), &cred); err != nil { + t.Fatalf("凭据应为 JSON(含 device_id/uid): %v (%q)", err, v) + } + if at, _ := cred["access_token"].(string); !strings.HasPrefix(at, "ACCESS-") { + t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", at) + } + // uid 来自 userInfo.UserID —— 签到设备身份的派生种子,丢了就无法签到 + if cred["uid"] != "u1" { + t.Fatalf("凭据应保留 uid=u1(签到设备身份的种子), 实得 %v", cred["uid"]) + } + if cred["enterprise_id"] != "t1" { + t.Fatalf("凭据应保留 TenantID, 实得 %v", cred["enterprise_id"]) + } + // 设备身份来自登录会话(授权 URL 里下发的那对 hex32) + if d, _ := cred["device_id"].(string); len(d) != 32 { + t.Fatalf("凭据应带 32 位 device_id(签到轮换用), 实得 %q", d) } }) @@ -131,8 +148,12 @@ func TestTraeCallbackBehavior(t *testing.T) { t.Fatalf("应 200: %d %s", st, body) } v, _, _ := h.ps.GetSecret("channels", "channels:trae") - if v != "JWT-TOKEN-1" { - t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %q", v) + var cred2 map[string]interface{} + if err := json.Unmarshal([]byte(v), &cred2); err != nil { + t.Fatalf("凭据应为 JSON: %v (%q)", err, v) + } + if cred2["access_token"] != "JWT-TOKEN-1" { + t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %v", cred2["access_token"]) } if atomic.LoadInt32(&h.exchHit) != before { t.Fatal("无 refreshToken 不应调 ExchangeToken(参考实现分支2)") @@ -208,7 +229,7 @@ func TestTraeCallbackPoolPath(t *testing.T) { // ① 凭据必须落在池账号的 ref v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid) if !ok || v == "" { - t.Fatalf("★ callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid) + t.Fatalf("callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid) } // ② account_list 必须报 has_credential=true lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{ diff --git a/internal/core/channels_ttl_cache_test.go b/internal/core/channels_ttl_cache_test.go new file mode 100644 index 0000000..ac733ad --- /dev/null +++ b/internal/core/channels_ttl_cache_test.go @@ -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"]) + } +} diff --git a/internal/core/channels_xdisplay_test.go b/internal/core/channels_xdisplay_test.go new file mode 100644 index 0000000..4c3125a --- /dev/null +++ b/internal/core/channels_xdisplay_test.go @@ -0,0 +1,191 @@ +package core + +import ( + "os" + "strconv" + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。 +// +// 现场(2026-10-13):/tmp/.X-lock 在 Xvfb 被 SIGKILL 后会留下(插件收尾 +// 就是 `kill -9`)。而旧选号逻辑是「锁/socket 存在就当被占」→ 每个被强杀的 +// 显示器**永久烧掉一个号**。反复跑测试/反复登录后 91 起的整段号被占满 +// (实测 41/41),于是 pickFree 扫不到空位 → `return start`(91) → Xvfb 报 +// “Server is already active for display 91”。 +// 表现为 TestChannelsVncStackEndToEnd 单独 1.07s 通过、全量跑 15s 超时失败。 +// +// 判据是**行为**,分三步(注意:插件占用期间锁存在是**正确**的 —— Xvfb 自己 +// 会写锁;错的是「用完之后不清理」和「被别人的陈旧锁挡死」): +// 1. 陈旧锁不得挡住选号(记录 PID 已死)→ 能正常起来,且不报 already active; +// 2. 空闲号被选中后,锁里应是**真实存活的** PID(即插件真占用了它); +// 3. 收栈之后,该号的锁与 socket 必须被清掉(这才是泄漏的根治)。 +func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) { + const ghost = "999999" // 必然不存在的 PID + // 造 3 个陈旧锁,并要求插件**复用**其中一个。 + // 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。 + ghosts := []int{91, 92, 93} + for _, n := range ghosts { + l := "/tmp/.X" + strconv.Itoa(n) + "-lock" + if _, err := os.Stat(l); err == nil { + t.Skipf("前置条件不满足:%s 已存在(先清理测试环境再跑)", l) + } + if err := os.WriteFile(l, []byte(ghost+"\n"), 0o644); err != nil { + t.Skipf("无法在 /tmp 造锁(跳过): %v", err) + } + defer os.Remove(l) + } + lock := "/tmp/.X91-lock" + sock := "/tmp/.X11-unix/X91" + + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + // 12 个真实平台里已无 vnc 流,故注入一个夹具(夹具不写进生产目录)。 + injectTestPlatforms(t, ps, `[{ + "id": "xdispfx", "name": "显示器夹具", "flow": "vnc", "needs_browser": true, + "login_url": "http://127.0.0.1:18099/login", + "base_url": "https://api.xdispfx.example/v1", "adapter": "openai", + "models": ["x-model-1"] + }]`) + + out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"xdispfx"}`, Role: "admin", + }) + if err != nil { + t.Fatalf("start_login: %v", err) + } + j, _ := out.JSON.(map[string]interface{}) + if j == nil { + t.Fatalf("无 JSON 响应 (status=%d)", out.Status) + } + msg, _ := j["browser_error"].(string) + if strings.Contains(msg, "already active for display") { + t.Fatalf("陈旧锁把选号挡死了(应回收):%s", msg) + } + if msg != "" { + // 缺浏览器依赖起不来是允许的 —— 但陈旧锁仍必须被回收(不占号)。 + if _, err := os.Stat(lock); err == nil { + t.Errorf("陈旧锁未被回收: %s(记录 PID %s 已不存在)", lock, ghost) + } + t.Skipf("浏览器栈未启动(与选号无关): %s", msg) + } + + // ② 插件确实占用了某个显示器,且锁里是**活着的** PID(不是那个幽灵)。 + st, err := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{ + Method: "GET", Role: "admin", + }) + if err != nil { + t.Fatalf("browser_status: %v", err) + } + sess, _ := st.JSON.(map[string]interface{})["session"].(map[string]interface{}) + used := 0 + if sess != nil { + if f, ok := sess["display"].(float64); ok { + used = int(f) + } + } + if used == 0 { + t.Fatalf("会话未报告 display: %v", st.JSON) + } + // 关键断言:选中的必须是那 3 个陈旧号之一 —— 证明陈旧锁被**回收复用**了。 + // 只跳过不复用的实现(变异)会选 94,从而被抓到。 + reclaimed := false + for _, n := range ghosts { + if used == n { + reclaimed = true + } + } + if !reclaimed { + t.Errorf("选中的 :%d 不是陈旧号 %v —— 陈旧锁没被回收复用(号段会被逐个烧掉,最终饿死)", used, ghosts) + } + if raw, err := os.ReadFile("/tmp/.X" + strconv.Itoa(used) + "-lock"); err == nil { + pid := strings.TrimSpace(string(raw)) + if pid == ghost { + t.Errorf("选中的显示器 :%d 锁里仍是幽灵 PID(陈旧锁没被换掉)", used) + } else if pid != "" { + if _, err := os.Stat("/proc/" + pid); err != nil { + t.Errorf("选中的显示器 :%d 锁里 PID %s 不存在(记录与事实不符)", used, pid) + } + } + } else { + t.Logf(":%d 未生成锁文件(部分 Xvfb 行为,可接受)", used) + } + + // ③ 收栈后必须不留锁/socket —— 这条直接钉住「用 kill -9 收尾却不清理」的泄漏。 + if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{ + Method: "POST", Role: "admin", + }); err != nil { + t.Fatalf("stop_browser: %v", err) + } + dl := "/tmp/.X" + strconv.Itoa(used) + "-lock" + ds := "/tmp/.X11-unix/X" + strconv.Itoa(used) + if _, err := os.Stat(dl); err == nil { + t.Errorf("收栈后锁未清理: %s(下次会把这个号误判为被占 → 号被永久烧掉)", dl) + _ = os.Remove(dl) + } + if _, err := os.Stat(ds); err == nil { + t.Errorf("收栈后 socket 未清理: %s", ds) + _ = os.Remove(ds) + } + _ = sock +} + +// 反向判据:**活着的**占用不得被回收(否则会把别人正在用的显示器抢走)。 +func TestXDisplaySelectionKeepsLiveLocks(t *testing.T) { + const disp = 92 + lock := "/tmp/.X" + strconv.Itoa(disp) + "-lock" + if _, err := os.Stat(lock); err == nil { + t.Skipf("前置条件不满足:%s 已存在", lock) + } + // 用一个**真实存活**的 PID(本进程)写锁。 + if err := os.WriteFile(lock, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil { + t.Skipf("无法在 /tmp 造锁(跳过): %v", err) + } + defer os.Remove(lock) + + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + injectTestPlatforms(t, ps, `[{ + "id": "xdispfx2", "name": "显示器夹具2", "flow": "vnc", "needs_browser": true, + "login_url": "http://127.0.0.1:18097/login", + "base_url": "https://api.xdispfx2.example/v1", "adapter": "openai", + "models": ["x-model-2"] + }]`) + defer func() { + _, _ = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{ + Method: "POST", Role: "admin", + }) + }() + + if _, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: `{"platform":"xdispfx2"}`, Role: "admin", + }); err != nil { + t.Fatalf("start_login: %v", err) + } + + // 记录的是活 PID → 该锁必须原样保留(不得抢占别人正在用的显示器)。 + raw, err := os.ReadFile(lock) + if err != nil { + t.Fatalf("活锁被误回收: %s(记录的是真实存活 PID,不得回收)", lock) + } + if got := strings.TrimSpace(string(raw)); got != strconv.Itoa(os.Getpid()) { + t.Errorf("活锁内容被改动: %q(应保持原样)", got) + } +} diff --git a/internal/core/channels_zcode_test.go b/internal/core/channels_zcode_test.go index f152707..d973a34 100644 --- a/internal/core/channels_zcode_test.go +++ b/internal/core/channels_zcode_test.go @@ -182,7 +182,7 @@ func TestZcodeInitPollBehavior(t *testing.T) { t.Fatalf("池路径应完成: %v", st) } if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok { - t.Fatalf("★ 池路径凭据未落到 channels:zcode:%s", aid) + t.Fatalf("池路径凭据未落到 channels:zcode:%s", aid) } }) } diff --git a/internal/core/codearts_sig_expected.json b/internal/core/codearts_sig_expected.json new file mode 100644 index 0000000..7f3a2b8 --- /dev/null +++ b/internal/core/codearts_sig_expected.json @@ -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" + } +] diff --git a/internal/core/codearts_sign_test.go b/internal/core/codearts_sign_test.go new file mode 100644 index 0000000..e3af629 --- /dev/null +++ b/internal/core/codearts_sign_test.go @@ -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) + } + } + } + } +} diff --git a/internal/core/device_mutate_test.go b/internal/core/device_mutate_test.go index 223e4d0..528e6d6 100644 --- a/internal/core/device_mutate_test.go +++ b/internal/core/device_mutate_test.go @@ -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) } }) } diff --git a/internal/core/kernel_actions.go b/internal/core/kernel_actions.go index d5f9fdc..ffe2722 100644 --- a/internal/core/kernel_actions.go +++ b/internal/core/kernel_actions.go @@ -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 diff --git a/internal/core/kernel_actions_test.go b/internal/core/kernel_actions_test.go index cf3a6f7..8224d3e 100644 --- a/internal/core/kernel_actions_test.go +++ b/internal/core/kernel_actions_test.go @@ -46,6 +46,24 @@ func kernelTestCore(t *testing.T) *Core { if err != nil { t.Fatal(err) } + // 测试结束**必须**把插件拉起的服务端浏览器栈收掉。 + // 为何必需:callback 流(trae/gemini/有道)在 start_login 时就起一整套 + // Xvfb+chromium+x11vnc+websockify,而绝大多数测试不会显式 stop_browser、 + // 会话 TTL 也还没到 —— 于是一轮 core 全包跑完就攒出 7 套常驻进程 + // (实测 websockify×7 + x11vnc×7 + 7 个 profile 目录),把显示器号与端口 + // 段逐步耗光,后续测试报「chromium/websockify 端口未就绪」。 + // 在这一处统一收掉,比让每个测试各自记得更可靠。 + t.Cleanup(func() { + ps := c.Plugins() + if ps == nil { + return + } + for _, name := range []string{"channels"} { + _, _ = ps.InvokeHandler(name, "stop_browser", &lua.HandlerRequest{ + Method: "POST", Role: "admin", + }) + } + }) t.Cleanup(c.Close) return c } diff --git a/internal/core/kernel_effect_test.go b/internal/core/kernel_effect_test.go index f873f69..c583e12 100644 --- a/internal/core/kernel_effect_test.go +++ b/internal/core/kernel_effect_test.go @@ -34,6 +34,6 @@ return plugin t.Fatalf("动作未成功: %#v", m) } if _, ok := c.FindKey("sk-kernel-0001"); ok { - t.Fatal("★ 动作报告成功但副作用没发生 —— dispatch 是空转") + t.Fatal("动作报告成功但副作用没发生 —— dispatch 是空转") } } diff --git a/internal/core/mutation_guard_test.go b/internal/core/mutation_guard_test.go new file mode 100644 index 0000000..2959192 --- /dev/null +++ b/internal/core/mutation_guard_test.go @@ -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()) +} diff --git a/internal/core/trae_derive_expected.json b/internal/core/trae_derive_expected.json new file mode 100644 index 0000000..41641ca --- /dev/null +++ b/internal/core/trae_derive_expected.json @@ -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" + } +] diff --git a/internal/gateway/billing_rules_api_test.go b/internal/gateway/billing_rules_api_test.go index 2a4a88b..82cf1f0 100644 --- a/internal/gateway/billing_rules_api_test.go +++ b/internal/gateway/billing_rules_api_test.go @@ -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 { diff --git a/internal/gateway/mutation_guard_test.go b/internal/gateway/mutation_guard_test.go new file mode 100644 index 0000000..364fbe1 --- /dev/null +++ b/internal/gateway/mutation_guard_test.go @@ -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) + } +} diff --git a/internal/gateway/stats_period_test.go b/internal/gateway/stats_period_test.go index b04e74a..78b6a6b 100644 --- a/internal/gateway/stats_period_test.go +++ b/internal/gateway/stats_period_test.go @@ -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'") } } diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index f84d2da..e4b0091 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -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", diff --git a/internal/gateway/ui_plugin_test.go b/internal/gateway/ui_plugin_test.go index 8e258a8..a4efd91 100644 --- a/internal/gateway/ui_plugin_test.go +++ b/internal/gateway/ui_plugin_test.go @@ -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") } diff --git a/internal/lua/adapter_prelude.go b/internal/lua/adapter_prelude.go new file mode 100644 index 0000000..c1a0c59 --- /dev/null +++ b/internal/lua/adapter_prelude.go @@ -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.`" + ` 拿到中性实现再改。 + +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. … 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 错误页取 ,否则取首个非空非标签行 + local title = string.match(body or "", "<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 +} diff --git a/internal/lua/adapter_prelude_equivalence_test.go b/internal/lua/adapter_prelude_equivalence_test.go new file mode 100644 index 0000000..6081230 --- /dev/null +++ b/internal/lua/adapter_prelude_equivalence_test.go @@ -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) + } +} diff --git a/internal/lua/adapter_prelude_test.go b/internal/lua/adapter_prelude_test.go new file mode 100644 index 0000000..432e823 --- /dev/null +++ b/internal/lua/adapter_prelude_test.go @@ -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) + } +} diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index e22b9d4..7c0d9c6 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -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 diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua index 03b609b..33cec83 100644 --- a/internal/lua/adapters/github.lua +++ b/internal/lua/adapters/github.lua @@ -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 diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua index 53fd8ad..252603b 100644 --- a/internal/lua/adapters/groq.lua +++ b/internal/lua/adapters/groq.lua @@ -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 diff --git a/internal/lua/adapters/kimicode.lua b/internal/lua/adapters/kimicode.lua index 3402898..1509a1e 100644 --- a/internal/lua/adapters/kimicode.lua +++ b/internal/lua/adapters/kimicode.lua @@ -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 diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua index d90986b..a40b0d6 100644 --- a/internal/lua/adapters/mistral.lua +++ b/internal/lua/adapters/mistral.lua @@ -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) diff --git a/internal/lua/adapters/openai.lua b/internal/lua/adapters/openai.lua index 3022e58..56038b2 100644 --- a/internal/lua/adapters/openai.lua +++ b/internal/lua/adapters/openai.lua @@ -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 text (e.g. "413 Request Entity Too Large") - local title = string.match(body or "", "<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 diff --git a/internal/lua/adapters/sensenova.lua b/internal/lua/adapters/sensenova.lua index 1840e14..7479640 100644 --- a/internal/lua/adapters/sensenova.lua +++ b/internal/lua/adapters/sensenova.lua @@ -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 diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index cfb018b..a679f5c 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -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"]) } } diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index 8d11ad4..be44ee7 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -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, "") 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) } } diff --git a/internal/lua/channels_gemini_adapter_test.go b/internal/lua/channels_gemini_adapter_test.go new file mode 100644 index 0000000..31964ca --- /dev/null +++ b/internal/lua/channels_gemini_adapter_test.go @@ -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 +} diff --git a/internal/lua/channels_trae_solo_test.go b/internal/lua/channels_trae_solo_test.go new file mode 100644 index 0000000..fbb329a --- /dev/null +++ b/internal/lua/channels_trae_solo_test.go @@ -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) + } +} diff --git a/internal/lua/fastvalue_test.go b/internal/lua/fastvalue_test.go index 75d47f0..b82cda5 100644 --- a/internal/lua/fastvalue_test.go +++ b/internal/lua/fastvalue_test.go @@ -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") } } diff --git a/internal/lua/fire_parallel_test.go b/internal/lua/fire_parallel_test.go index 7480a1e..7ad5fd7 100644 --- a/internal/lua/fire_parallel_test.go +++ b/internal/lua/fire_parallel_test.go @@ -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 { diff --git a/internal/lua/outbound_grant_persist_test.go b/internal/lua/outbound_grant_persist_test.go new file mode 100644 index 0000000..26bd9d4 --- /dev/null +++ b/internal/lua/outbound_grant_persist_test.go @@ -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 不该被放行") + } +} diff --git a/internal/lua/persist_test.go b/internal/lua/persist_test.go index 4a08e17..5c60921 100644 --- a/internal/lua/persist_test.go +++ b/internal/lua/persist_test.go @@ -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 { diff --git a/internal/lua/plugin_adapter_capability_test.go b/internal/lua/plugin_adapter_capability_test.go new file mode 100644 index 0000000..0c08709 --- /dev/null +++ b/internal/lua/plugin_adapter_capability_test.go @@ -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) + } +} diff --git a/internal/lua/plugin_bridges.go b/internal/lua/plugin_bridges.go index 56387de..3817fcc 100644 --- a/internal/lua/plugin_bridges.go +++ b/internal/lua/plugin_bridges.go @@ -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" diff --git a/internal/lua/plugin_runtime.go b/internal/lua/plugin_runtime.go index aadbded..b8cf1f8 100644 --- a/internal/lua/plugin_runtime.go +++ b/internal/lua/plugin_runtime.go @@ -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) diff --git a/internal/lua/plugin_runtime_test.go b/internal/lua/plugin_runtime_test.go index b2ec628..1923dca 100644 --- a/internal/lua/plugin_runtime_test.go +++ b/internal/lua/plugin_runtime_test.go @@ -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 } diff --git a/internal/lua/plugin_unload_test.go b/internal/lua/plugin_unload_test.go index 9a03a1e..9773dd3 100644 --- a/internal/lua/plugin_unload_test.go +++ b/internal/lua/plugin_unload_test.go @@ -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 未被调用") } } diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 2ce5e68..55cc278 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -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() diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index c1afeea..20fde5e 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -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) diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 55a47ed..16e784d 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -15,9 +15,9 @@ -- ------------------------ -- 内核提供基础设施,插件自己不实现这些: -- --- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它) --- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读) --- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…) +-- __llmsproxy_http 受出站白名单约束的 HTTP(登录/领额度都走它) +-- __llmsproxy_secrets 凭据保险库(0600、独立文件、跨插件不可读) +-- __llmsproxy_kernel 内核动作面(add_source / probe_all / reset_health…) -- -- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。 -- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"—— @@ -25,11 +25,11 @@ -- -- 权限(本插件需要,全部可在设置页看到"申请中") -- ------------------------------------------------ --- add_source 把平台端点注册为上游源 --- list_sources 显示当前已接入的平台 --- probe_all 接入后立刻验证可达 --- reset_health 上游恢复后清冷却 --- status 面板显示运行状态 +-- add_source 把平台端点注册为上游源 +-- list_sources 显示当前已接入的平台 +-- probe_all 接入后立刻验证可达 +-- reset_health 上游恢复后清冷却 +-- status 面板显示运行状态 -- -- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被 -- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。 @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -61,26 +61,38 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda -- ============================================================================ -- -- 每个平台声明登录方式与各端点。字段: --- flow link-poll(给 URL 轮询)/ callback(OAuth 重定向回来带 code)/ --- device-poll(设备码:先申请码、给用户 URL、再轮询换 token)/ --- paste(用户粘贴 cookie/token) --- login_url 用户要在自己浏览器打开的地址 --- base_url 接入网关后对话走的 OpenAI 兼容端点(adapter 决定协议形状) --- adapter 内核适配器名(openai / anthropic / …) --- models fallback 模型清单(登录后可用 refresh 动态拉取覆盖) --- checkin 每日额度领取:{ url, method, body, auth }(可缺省=不支持签到) +-- flow link-poll(给 URL 轮询)/ callback(OAuth 重定向回来带 code)/ +-- device-poll(设备码:先申请码、给用户 URL、再轮询换 token)/ +-- paste(用户粘贴 cookie/token) +-- login_url 用户要在自己浏览器打开的地址 +-- base_url 接入网关后对话走的 OpenAI 兼容端点(adapter 决定协议形状) +-- adapter 内核适配器名(openai / anthropic / …) +-- models fallback 模型清单(登录后可用 refresh 动态拉取覆盖) +-- checkin 每日额度领取:{ url, method, body, auth }(可缺省=不支持签到) -- -- 新增平台 = 加一条。不改内核,也不改本文件其它部分。 -- --- ⚠️ 真实厂商端点逆向自参考实现(dsh-our-free-model/vendor/channel-pack)。 +-- 注意:真实厂商端点逆向自参考实现(dsh-our-free-model/vendor/channel-pack)。 -- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。 -- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua -- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。 +-- 前向声明:本文件里**所有**会被「更早定义的代码」引用的 local 助手都要在此 +-- 统一占坑。Lua 的 local 作用域只覆盖其后的代码 —— 漏一个就是运行时的 +-- "attempt to call global 'x' (a nil value)",而编译期完全静默(本文件已踩多次: +-- qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion / +-- traeCallback 各一次)。 +local qoderMachineIdentity, rfc3986Escape, newToken +local lobsteraiClientVersion + +-- 前向声明(本文件第 10 次踩 local 前向引用):余额分档选号在前、具体抓取在后。 +-- expiringWindowDays / fetchAccountJSON / fetchBalanceOf 都定义在下方。 +local expiringWindowDays +local fetchAccountJSON + local PLATFORMS = { -- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)===== - -- - -- ⚠️ 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的 - -- 占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接 + -- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的 + -- 注意:占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接 -- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。 -- 夹具由测试经 channels/inject_platforms 端点运行时注入。 @@ -91,19 +103,59 @@ local PLATFORMS = { name = "TRAE(字节)", vendor = "bytedance", flow = "callback", - -- 用户在自己浏览器打开授权页;回调带 token 直传(auth_type=local)。 - login_url = "https://www.trae.cn/authorization", + -- 授权页(照参考 buildTraeLoginUrl 的完整参数集)。 + -- 注意参数名必须是 **auth_callback_url**(参考注释点名:早期用了 + -- redirect_uri/auth_callback 等错名,TRAE 拿不到回调地址、登录必然失败)。 + -- 端口用 {port} 占位,实际拼装时填本机监听端口。 + authorize = { + base = "https://www.trae.cn/authorization", + params = { + login_version = "1", auth_from = "solo", login_channel = "native_ide", + plugin_version = "2.3.62834", auth_type = "local", + client_id = "en1oxy7wnw8j9n", redirect = "0", + -- Trae 注册的回调路径是 /authorize(参考 trae-oauth.ts:565 + -- `http://127.0.0.1:${port}/authorize`)—— 换成别的路径授权页会拒绝。 + -- loopback 接收器会把它变成 http://127.0.0.1:{实际端口}/authorize。 + auth_callback_url = "{redirect_uri}", + -- 设备身份与反查凭据:参考 trae-oauth.ts 列为「一个都不能少」—— + -- login_trace_id 是回调**反查 pending** 的唯一凭据;machine_id/device_id + -- 缺席可能被风控拦;它们还会写进凭据供签到发 X-Device-Id(day 轮换躲 9074)。 + login_trace_id = "{trace_id}", + machine_id = "{machine_id}", device_id = "{device_id}", + x_device_id = "{device_id}", x_machine_id = "{machine_id}", + x_device_brand = "PC", x_device_type = "PC", x_os_version = "1.0", + x_app_version = "0.1.52", x_app_type = "stable", + }, + }, hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" }, + -- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat, +-- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。 base_url = "https://trae-api-cn.mchost.guru", - adapter = "openai", + -- TRAE 非 OpenAI 兼容,用**本插件自装**的 SOLO 适配器 + -- (见 TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。 + adapter = "channels-trae", + -- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden + -- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。 models = { - "DeepSeek-V4-Flash", "DeepSeek-V4-Pro", "kimi-k3", "kimi-k2.6", - "glm-5", "glm-5.2", "Doubao-Seed-2.1-Pro", + "DeepSeek-V4-Flash-Official", "Doubao-Seed-2.1-Pro", "seed-code-pro-0430", + "Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.0-Code", "glm-5.2", "glm-5-turbo", + "glm-5", "DeepSeek-V4-Pro", "DeepSeek-V4-Flash", "kimi-k3", "kimi-k2.7-code", + "kimi-k2.6", "minimax-m3", "qwen-3.7-plus", "sagitta", "aquila", + "custom_model_gemini", "custom_model_placeholder", "custom_model_1M_text", + "custom_model_1M", "custom_model_kimi", "custom_model_claude", + "custom_model_gpt-5", "custom_model_no-fc", "custom_model_deepseek_chat", + "custom_model_deepseek_reasoner", "custom_model_deepseek_v4", + }, + models_hidden = { + "browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary", }, -- 每日签到:POST /trae/api/v2/ug/checkin_credits/claim body {} 到 UG 主机。 checkin = { url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim", method = "POST", body = "{}", + -- 授权方案是 Cloud-IDE-JWT(不是 Bearer),且参考注释点明: + -- 简化头实测签到失败,必须带基于 user_id 派生的设备身份头(见 traeCheckinHeaders)。 + auth = "cloud-ide-jwt", }, -- 签到状态(对齐参考:POST /checkin_credits/status → {checked_in, credits, enable}) checkin_status = { @@ -134,10 +186,10 @@ local PLATFORMS = { end, }, -- callback 解析模式(移植自参考 trae-oauth.ts parseTraeCallbackDetailed): - -- trae token 直传:query 带 refreshToken / userJwt(JSON) / userInfo(JSON)。 - -- refreshToken 存在时调 ExchangeToken 换新(轮换旧 refresh); - -- 无 refreshToken 用 userJwt.Token 兑底; - -- 只有 code/authCodeInfo 时是 PKCE 流 → 明确报"暂不支持"而不是含糊失败。 + -- trae token 直传:query 带 refreshToken / userJwt(JSON) / userInfo(JSON)。 + -- refreshToken 存在时调 ExchangeToken 换新(轮换旧 refresh); + -- 无 refreshToken 用 userJwt.Token 兑底; + -- 只有 code/authCodeInfo 时是 PKCE 流 → 明确报"暂不支持"而不是含糊失败。 callback = { mode = "trae", exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken", @@ -167,8 +219,16 @@ local PLATFORMS = { }, hosts = { "account.minimax.cn", "agent.minimax.cn" }, base_url = "https://agent.minimax.cn", + -- 远端模型目录(照参考 minimax-product.ts MINIMAX_MODELS_PATH) + models_remote = { + endpoints = { { url = "https://www.minimaxi.com/mavis/api/v1/models", method = "GET" } }, + auth = "bearer", + parse = "id_list", + }, adapter = "openai", - models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed" }, + -- 照参考 MINIMAX_FALLBACK_MODELS(4 项)。远端权威: + -- GET /mavis/api/v1/models?region=cn&buildEnv=prod + models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7" }, -- 每日签到(两步:状态预检 + 领取;幂等靠服务端状态) checkin = { url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/claim", @@ -184,7 +244,7 @@ local PLATFORMS = { url = "https://agent.minimax.cn/minimax-cloud/api/v1/credit/details", method = "GET", total_fn = function(rec) - -- ⚠️ total_count 是记录条数不是余额(参考注释踩过的坑)。余额=Σ remaining_amount。 + -- 注意 total_count 是记录条数不是余额(参考注释踩过的坑)。余额=Σ remaining_amount。 if type(rec) ~= "table" or type(rec.details) ~= "table" then return 0 end local sum = 0 for _, d in ipairs(rec.details) do @@ -197,10 +257,10 @@ local PLATFORMS = { -- Cline —— WorkOS 设备码流(照参考 cline-oauth.ts / cline-product.ts 逆向)。 -- 与 MiniMax 同为 device-poll,但判据形态完全不同(参考注释反复强调): - -- 判据看响应体 error 字段(HTTP 可能非 2xx),不看 status 字段; - -- slow_down 累积退避 +1 秒(WorkOS 会持续限流不退避的客户端); - -- 成功后还有第二步 register:拿 WorkOS token 换 Cline 自己的 token - -- (POST /api/v1/auth/register,body 驼峰 accessToken/refreshToken)。 + -- 判据看响应体 error 字段(HTTP 可能非 2xx),不看 status 字段; + -- slow_down 累积退避 +1 秒(WorkOS 会持续限流不退避的客户端); + -- 成功后还有第二步 register:拿 WorkOS token 换 Cline 自己的 token + -- (POST /api/v1/auth/register,body 驼峰 accessToken/refreshToken)。 { id = "cline", name = "Cline", @@ -222,9 +282,31 @@ local PLATFORMS = { url = "https://api.cline.bot/api/v1/auth/register", method = "POST", body = '{"accessToken":"{access_token}","refreshToken":"{refresh_token}"}', + -- 静默续期(照参考 cline.ts)。 + -- ⚠️ 三个字段名陷阱,写错服务端只回泛化的认证失败、极难定位: + -- ① 请求体是**驼峰** `refreshToken` + `grantType`(不是 OAuth 的 + -- refresh_token/grant_type); + -- ② 响应套 `{success,data}` 信封; + -- ③ 响应字段是**驼峰** `accessToken`。 + refresh = { + style = "cline", + url = "https://api.cline.bot/api/v1/auth/refresh", + }, }, hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" }, - base_url = "https://api.cline.bot", + -- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。 + base_url = "https://api.cline.bot/api/v1/chat/completions", + -- 远端模型目录(照参考 cline-models.ts:**必须同时打两个端点**)。 + -- 关键:/api/v1/models 的 460 个 id 里**没有** cline-free/*,免费模型只由 + -- recommended-models 下发;只打前者会看不到任何免费模型。 + models_remote = { + endpoints = { + { url = "https://api.cline.bot/api/v1/ai/cline/recommended-models", method = "GET", url_auth = false }, + { url = "https://api.cline.bot/api/v1/models", method = "GET" }, + }, + auth = "bearer", + parse = "cline_merge", + }, adapter = "openai", -- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费) models = { @@ -269,7 +351,16 @@ local PLATFORMS = { interval = 1, }, hosts = { "copilot.tencent.com", "www.codebuddy.cn" }, - base_url = "https://copilot.tencent.com", + -- buddy 的 chat 端点含 /v2 前缀(参考 CHAT_API_BASE = .../v2 + /chat/completions)。 + base_url = "https://copilot.tencent.com/v2/chat/completions", + -- 远端模型目录(照参考 buddy.ts CONFIG_PATH + buddy-oauth.ts fetchModels)。 + -- 关键:必须带 product 身份头,否则 WorkBuddy 会以 CodeBuddy 身份请求。 + models_remote = { + endpoints = { { url = "https://copilot.tencent.com/v3/config", method = "GET" } }, + auth = "bearer", + headers_fn = true, + parse = "buddy_config", + }, adapter = "openai", -- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。 -- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。 @@ -315,17 +406,61 @@ local PLATFORMS = { end, }, -- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource) - -- - -- ⚠️ 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 + -- -- 注意响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 -- data 不同),这是本接口最容易解析错的地方。 - -- - -- ⚠️ 刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一 + -- -- 注意刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一 -- 响应里 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts[] -- 现算:只累加**有效包**的 CycleCapacityRemain(失效包服务端仍会返回, -- 但不能用于扣费,并进总额会让数字虚高),最后按两位小数规整。 balance = { url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource", method = "POST", body = "{}", + -- tier_fn:把余额拆成「快到期 / 永久」两桶(照参考 + -- buddy-balance-rank.ts:splitBuddyCreditsByExpiry)。口径必须与 total_fn + -- 一致 —— 两条路各算一遍必然会漂移,故共用同一套包遍历规则: + -- * **只算 remaining**(CycleCapacityRemain,本周期剩余),与 IDE 顶部 + -- 的 Credits Balance 一致。刻意不用 TotalDosage:那是**终身口径** + -- 且取整(实测同一响应 TotalDosage=655 而 IDE 显示 155.67)。 + -- * `active === false` 的包一律**跳过**:失效包服务端仍会返回余额, + -- 并进任何一桶都会虚增可用额度。 + -- * 到期时间**未知**归入**永久**桶 —— 保守方向:最坏是锁定时少用一个号, + -- 而不是误把长期积分当快到期烧掉。 + tier_fn = function(rec) + local data = rec.data + if type(data) ~= "table" then return nil end + local resp = data.Response + if type(resp) ~= "table" then return nil end + local inner = resp.Data + if type(inner) ~= "table" then return nil end + local accounts = inner.Accounts + if type(accounts) ~= "table" then return nil end + local now = os.time and os.time() or 0 + local window = expiringWindowDays() * 86400 + local expiring, permanent = 0, 0 + for _, pkg in ipairs(accounts) do + if type(pkg) == "table" then + local active = true + local st = pkg.Status + if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end + local ded = tonumber(pkg.DeductionEndTime) + local endSec = nil + if ded and ded > 0 then + endSec = math.floor(ded / 1000) + if now >= endSec then active = false end + end + local remain = tonumber(pkg.CycleCapacityRemain) or 0 + if active and remain > 0 then + -- 到期时间已知且落在窗口内 → 临时桶;未知 → 永久桶(保守) + if endSec and (endSec - now) < window then + expiring = expiring + remain + else + permanent = permanent + remain + end + end + end + end + return { expiring_balance = expiring, permanent_balance = permanent } + end, total_fn = function(rec) local data = rec.data if type(data) ~= "table" then return nil end @@ -379,23 +514,65 @@ local PLATFORMS = { name = "Gemini(Cloud Code)", vendor = "google", flow = "callback", - login_url = "https://accounts.google.com/o/oauth2/v2/auth" - .. "?client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" - .. "&redirect_uri=http://127.0.0.1:8845/auth/callback" - .. "&response_type=code" - .. "&scope=openid+email+profile+cloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email" - .. "&include_granted_scopes=true", + login_url = "https://accounts.google.com/o/oauth2/v2/auth", + -- login_url 已含完整 OAuth 参数(client_id/scope/response_type), + -- 只剩 redirect_uri 与 state 需要按会话填 —— 用 authorize 规格声明, + -- 避免通用分支再追加一遍参数(造成 redirect_uri 出现两次)。 + authorize = { + base = "https://accounts.google.com/o/oauth2/v2/auth", + params = { + client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + -- 回调路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH), + -- 主机是 localhost 而非 127.0.0.1(参考注释明确);路径/主机改一个就是 + -- redirect_uri_mismatch。 + redirect_uri = "http://localhost:{port}/oauth-callback", + response_type = "code", + -- 六项 scope 逐字照抄参考 GEMINI_SCOPES(cloud-platform 必需) + scope = "openid https://www.googleapis.com/auth/cloud-platform " + .. "https://www.googleapis.com/auth/userinfo.email " + .. "https://www.googleapis.com/auth/userinfo.profile " + .. "https://www.googleapis.com/auth/cclog " + .. "https://www.googleapis.com/auth/experimentsandconfigs", + -- 参考三个固定参数(缺 access_type=offline 拿不到 refresh_token) + access_type = "offline", + prompt = "consent", + include_granted_scopes = "true", + }, + }, callback = { mode = "code", token_url = "https://oauth2.googleapis.com/token", + -- ⚠️ 必须带 client_secret:token 端点强制校验客户端身份,只发 client_id 会回 + -- invalid_request: client_secret is missing,症状正是「浏览器显示授权成功, + -- 但面板里账号一直不出现」。redirect_uri 必须与授权时**完全一致**(含主机名)。 token_body = "grant_type=authorization_code&code={code}" .. "&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" - .. "&redirect_uri=http://127.0.0.1:8845/auth/callback", - pkce = true, -- 授权页带 verifier(S256),交换时回传 + .. "&client_secret=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" + .. "&redirect_uri=http://localhost:{port}/oauth-callback", + -- 参考明言:上游 Cloud Code 用的是**最朴素的 authorization_code + + -- client_secret**,刻意不带 PKCE。 + }, + -- 静默续期(照参考 gemini-oauth):标准 OAuth `refresh_token` 表单(**平台级**, + -- 不是 callback 内 —— refreshCredential 读的是 plat.refresh)。 + -- 同样必须带 client_secret;且 **Google 偶尔轮换 refresh_token**, + -- 响应给了新的必须回写(由 refreshCredential 负责),否则下次用旧的直接失效。 + refresh = { + style = "google", + url = "https://oauth2.googleapis.com/token", + client_id = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + client_secret = "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", }, hosts = { "accounts.google.com", "oauth2.googleapis.com", "cloudcode-pa.googleapis.com" }, base_url = "https://cloudcode-pa.googleapis.com", - adapter = "gemini", + -- Cloud Code 走**私有信封协议**(/v1internal: 动词 + 身份五头 + 字母序 + -- 信封 + 响应剥一层),用本插件自装的适配器。 + adapter = "channels-gemini", + -- ⚠️ Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent; + -- 参考 gemini.ts:78 明确走私有动词路径 /v1internal:generateContent + -- (流式 /v1internal:streamGenerateContent?alt=sse)。用声明式 endpoint + -- 覆盖,内核只当不透明字符串。 + endpoint = "/v1internal:generateContent", + endpoint_stream = "/v1internal:streamGenerateContent?alt=sse", models = { "gemini-3.1-flash-lite-preview", "gemini-3.1-flash-preview" }, }, @@ -405,16 +582,71 @@ local PLATFORMS = { name = "LobsterAI(有道)", vendor = "youdao", flow = "callback", - login_url = "https://lobsterai.youdao.com/login", + -- 授权页真值:{portal}/portal#/login?source=electron&redirect_uri=<编码>&state= + -- (参考 buildLobsteraiLoginUrl)。注意 login 在 **hash 段**,且三个参数都是 + -- query —— 此前我写的 ".../login&redirect_uri=..." 既缺 /portal#、又把 & 当 + -- 首个分隔符(应为 ?),授权页必然报参数错误。 + -- 授权页真值(照参考 buildLobsteraiLoginUrl):login 在 **hash 段**, + -- 三个 query 参数是 source/redirect_uri/state。 + -- 此前我写的 ".../login&redirect_uri=..." 既缺 /portal#、又把 & 当首个 + -- 分隔符(应为 ?),授权页必然报参数错误。 + login_url = "https://lobsterai.youdao.com/portal#/login", + authorize = { + hash = "https://lobsterai.youdao.com/portal#/login", + params = { + source = "electron", + -- 参考注释:**必须**是 http://127.0.0.1:{port}/auth/callback 形态, + -- 登录页会校验。此处的 redirect_uri 由**本机 loopback 接收器**决定 + -- (不是本插件的 /api/plugins/http 挂载点,也不是用户机器的 localhost), + -- 故用 {redirect_uri} 占位,拼装时由会话的实际接收器地址填入。 + redirect_uri = "{redirect_uri}", + }, + }, callback = { mode = "code", - token_url = "https://lobsterai-server.youdao.com/api/auth/login", - token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"cli","uuid":"","version":"1.0"}', + -- 参考真值 LOBSTERAI_EXCHANGE_PATH。此前写的 /api/auth/login 是我编的。 + token_url = "https://lobsterai-server.youdao.com/api/auth/exchange", + -- 请求体必须含这 5 个字段(照参考 exchangeLobsteraiAuthCode)。 + -- 兜底模板(正常路径走 lobsteraiExchange 助手,它会动态解析版本号)。 + token_body = '{"authCode":"{code}","firstKeyfrom":"cli","latestKeyfrom":"{now}","uuid":"{uuid}","version":"2026.9.4"}', + }, + -- 交换端点不需要 Authorization(此时还没有 token),但要这套匿名头。 + callback_headers = { + ["User-Agent"] = "LobsterAI/0.1.0", + }, + hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com", + "api-overmind.youdao.com" }, + -- 客户端版本号:exchange 的 `version` 与签到 query 都必填。 + -- **不能写死**:2026-10-13 实测该端点返回 2026.9.23,而参考的兜底常量 + -- 还停在 2026.9.4 —— 写死就会发出一个过期的版本号。 + -- 响应形态非统一信封:外层无 code,值在 data.value.version。 + client_version = { + url = "https://api-overmind.youdao.com/openapi/get/luna/hardware/lobsterai/prod/update", + path = "data.value.version", + fallback = "2026.9.4", + }, + -- lobsterai 的 chat 路径是 /api/proxy/v1/chat/completions(参考 LOBSTERAI_CHAT_PATH)。 + base_url = "https://lobsterai-server.youdao.com/api/proxy/v1/chat/completions", + -- 远端模型目录(照参考 lobsterai.ts LOBSTERAI_MODELS_PATH) + models_remote = { + -- 参考真值 LOBSTERAI_API_BASE(lobsterai-server.youdao.com)。 + -- 此前写的 api.lobster.ai 是我编的(DNS 都不解析)。 + endpoints = { { url = "https://lobsterai-server.youdao.com/api/models/available", method = "GET" } }, + auth = "bearer", + parse = "id_list", }, - hosts = { "lobsterai.youdao.com", "lobsterai-server.youdao.com" }, - base_url = "https://lobsterai-server.youdao.com", adapter = "openai", - models = { "kimi-k3", "glm-5.2", "deepseek-v4-flash" }, + -- 照参考 LOBSTERAI_FALLBACK_MODELS 全量(19 项)。远端权威: + -- GET /api/models/available(**必须带 model_flag 参数**,否则只返回 25 个 + -- 且没有 kimi-k3 —— 参考注释点名)。 + models = { + "deepseek-v4-flash", "deepseek-v4-pro", "MiniMax-M3", "MiniMax-M2.7", + "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus-2026-04-20", + "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5", + "doubao-seed-2-1-pro-260628", "doubao-seed-2-1-turbo-260628", + "doubao-seed-2-0-code-preview-260215", "glm-5.2", "glm-5.1", + "glm-5v-turbo", "glm-5", + }, -- 余额(对齐参考:GET /api/user/profile-summary) balance = { url = "https://lobsterai-server.youdao.com/api/user/profile-summary", @@ -432,9 +664,30 @@ local PLATFORMS = { name = "OpenCode Zen", vendor = "opencode", flow = "paste", - base_url = "https://opencode.ai/zen", + -- opencode 的 chat 路径是 /v1/chat/completions(参考 chatPath)。 + base_url = "https://opencode.ai/zen/v1/chat/completions", + -- 远端模型目录(照参考 opencode-product.ts modelsPath) + models_remote = { + endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } }, + auth = "bearer", + parse = "id_list", + }, adapter = "openai", - models = { "big-pickle", "claude-sonnet-4-20250514", "gpt-5.1-codex-mini" }, + -- models.dev 的键名(能力元数据的来源;照参考 opencode-capability) + capability_key = "opencode", + -- 照参考 OPENCODE_FALLBACK_MODELS 全量(14 项)。isFree 是**官方语义 + -- 字段**(匿名可用与否):7 个 free 无需 key,其余需付费 key(实测匿名 401)。 + models = { + "big-pickle", "space-bunny-free", "longcat-2.5-preview-free", + "mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free", + "nemotron-3.5-lightning-free", "deepseek-v4-flash", "deepseek-v4.1-flash", + "glm-5.2", "kimi-k2.5", "minimax-m2.7", "minimax-m3", "qwen3.8-max", + }, + models_free = { + "big-pickle", "space-bunny-free", "longcat-2.5-preview-free", + "mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free", + "nemotron-3.5-lightning-free", + }, }, -- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。 @@ -444,9 +697,27 @@ local PLATFORMS = { id = "codearts", name = "CodeArts(华为)", vendor = "huawei", - flow = "paste", + -- 不是 paste!参考默认走**新式 IAM OAuth**(PKCE + DPoP), + -- 旧 ticket 流程仅作回退。我此前标成手填凭据是错的。 + flow = "callback", + -- 回调路径必须是 /oauth/callback(参考 REDIRECT_PATH),且端口须 ≥10000 + -- (portal 拒绝低端口)。 + authorize = { + base = "https://codearts.huaweicloud.com/portal/authorize", + callback_path = "/oauth/callback", + min_port = 10000, + -- 参数由 buildCodeartsAuthorizeUrl 按参考逐字拼(含 SHA-256 而非 S256) + }, + hosts = { "codearts.huaweicloud.com", "sts.cn-north-4.myhuaweicloud.com", + "snap-access.cn-north-4.myhuaweicloud.com", + "devcloud.cn-north-4.huaweicloud.com", "auth.huaweicloud.com" }, + -- codearts 的 chat 端点含 /api/v2 前缀(参考 CHAT_API_BASE = .../api/v2)。 base_url = "https://snap-access.cn-north-4.myhuaweicloud.com", - adapter = "openai", + -- 本插件**自己装的**适配器(见 installCodeartsAdapter):华为云 AK/SK 签名 + adapter = "codearts", + -- 签名平台:源注册要交**完整凭据 JSON**(适配器自己解 AK/SK 签名), + -- 不是裸 access token。见 syncAccountSource 的注释。 + credential_shape = "full", models = { "CodeArts-Pangu" }, checkin = { url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin", @@ -457,18 +728,17 @@ local PLATFORMS = { }, -- Qoder —— **标准 PKCE 设备码轮询**(照参考 qoder-oauth.ts / qoder.ts 逐行移植)。 - -- - -- ⚠️ 纠正一处长期错误:此前把 Qoder 标成 manual,理由是"登录要 WASM 签名头"。 + -- -- 注意纠正一处长期错误:此前把 Qoder 标成 manual,理由是"登录要 WASM 签名头"。 -- 那是把**聊天加密**(WASM 用于 /algo/ 加密推理端点)与**登录**混为一谈 —— -- 登录本身是纯 HTTP 的 PKCE 设备码流,Lua 完全能做: - -- ① 打开 {authBase}/device/selectAccounts?challenge&challenge_method=S256 - -- &nonce&machine_id&client_id - -- ② 轮询 {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256 - -- 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成 + -- ① 打开 {authBase}/device/selectAccounts?challenge&challenge_method=S256 + -- &nonce&machine_id&client_id + -- ② 轮询 {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256 + -- 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成 -- 三个必须照抄的细节(参考注释都点了名): - -- · client_id 必须用 **prod 的**(非 prod 会让服务端在授权回调阶段拒绝) - -- · poll 挂在 openApiBase(openapi.qoder.sh),不是 authBase(那返回 401) - -- · nonce/verifier 必须原样回传(PKCE 校验) + -- · client_id 必须用 **prod 的**(非 prod 会让服务端在授权回调阶段拒绝) + -- · poll 挂在 openApiBase(openapi.qoder.sh),不是 authBase(那返回 401) + -- · nonce/verifier 必须原样回传(PKCE 校验) { id = "qoder", name = "Qoder", @@ -490,11 +760,43 @@ local PLATFORMS = { judge = "pending404", interval = 2, }, - base_url = "https://api2-v2.qoder.sh", + -- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。 + base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions", adapter = "openai", - models = { "qoder-default" }, + -- 兜底目录(照参考 QODER_FALLBACK_MODELS 的真实目录 key)。 + -- 关键:这些是**上游目录 key**(如 qfmodel),不是通用名 —— 公开的 + -- /model/v1/chat/completions 不认它们,必须走加密端点 agent_chat_generation。 + -- 此前这里写的是 "qoder-default"(我编的占位名,上游不认,选了也调不通)。 + -- 远端目录端点 /api/v2/model/list 需 WASM 签名,故参考自己也用兜底表。 + models = { + "auto", "ultimate", "performance", "efficient", "smodel", "cmodel", + "qmodel_38max", "qfmodel", "qmodel_latest", "qmodel", + "kmodel_latest", "kmodel", "gmodel", "gfmodel", + "dmodel", "dfmodel", "mmodel", + -- CN 独有(参考注释点名:`q37fmodel` / `gm51model` 仅国区下发, + -- 两者都是 1M 上下文、支持图像与思考) + "q37fmodel", "gm51model", + }, -- 余额(对齐参考 qoder-credits.ts:GET /sash/api/v2/me/usage,余额=userQuota.remaining -- + addOnQuota.remaining,走 openApiBase) + -- 身份头:cosy-clienttype 是必要但**不充分**条件,服务端还校验设备身份 + -- (见 qoderMachineIdentity 的函数注释:缺 Cosy-MachineToken/MachineType + -- 时不下发可领取的活动)。拿不到身份时照常发(保守降级)。 + headers_fn = function(accessToken, cred) + local h = { + ["Authorization"] = "Bearer " .. accessToken, + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["Cosy-ClientType"] = "10", + } + local id = qoderMachineIdentity() + if id then + -- 必须成对出现(消融表:缺一即失效) + h["Cosy-MachineToken"] = id.token + h["Cosy-MachineType"] = id.type + end + return h + end, balance = { url = "https://openapi.qoder.sh/sash/api/v2/me/usage", method = "GET", @@ -508,23 +810,22 @@ local PLATFORMS = { }, -- ZCode —— **两段式服务端中介授权流**(照参考 zcode-login.ts 逐行移植)。 - -- - -- ⚠️ 纠正第二处长期错误:此前标 manual,理由是"登录要解 captcha"。核对参考 + -- -- 注意纠正第二处长期错误:此前标 manual,理由是"登录要解 captcha"。核对参考 -- 后发现**该授权流完全不涉及 captcha** —— captcha 属于另一条已废弃的旁路 -- (zcode-captcha.ts,用于读取本机官方客户端数据),而本流的注释明确写着 -- 「完全不经 zcode:// 自定义协议回调,故普通进程就能走完」: - -- ① POST {origin}/api/v1/oauth/cli/init - -- Authorization: Bearer <本地造的 flow_secret(32 字节 hex)> - -- body {provider:"bigmodel"} - -- → {flow_id, poll_token, authorize_url, expires_at, poll_interval_sec} - -- ② 用户在浏览器打开 authorize_url 完成授权 - -- ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} - -- Authorization: Bearer <同一个 flow_secret> - -- → {status:"pending"} 继续 / {status:"ready", token, user, bigmodel|zai} + -- ① POST {origin}/api/v1/oauth/cli/init + -- Authorization: Bearer <本地造的 flow_secret(32 字节 hex)> + -- body {provider:"bigmodel"} + -- → {flow_id, poll_token, authorize_url, expires_at, poll_interval_sec} + -- ② 用户在浏览器打开 authorize_url 完成授权 + -- ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} + -- Authorization: Bearer <同一个 flow_secret> + -- → {status:"pending"} 继续 / {status:"ready", token, user, bigmodel|zai} -- 判据(参考注释点名,与官方一致): - -- · 4xx(**除 408/429**)才是终态失败;5xx 与网络错误继续重试 - -- · code != 0 或 data 缺失 → pending - -- · token(zcode JWT)与 user.user_id 必需;第三方 token 取 bigmodel/zai 二者之一 + -- · 4xx(**除 408/429**)才是终态失败;5xx 与网络错误继续重试 + -- · code != 0 或 data 缺失 → pending + -- · token(zcode JWT)与 user.user_id 必需;第三方 token 取 bigmodel/zai 二者之一 { id = "zcode", name = "ZCode", @@ -541,7 +842,17 @@ local PLATFORMS = { -- 推理走 Anthropic 协议的免费额度通道(照参考 zcode-upstream.ts): -- 积分制无论 bigmodel/zai 都走 zcode.z.ai(官方分派表规则 2/5,2026-10-03 实测); -- 只有订阅制才走 api.z.ai。这里用积分制那条。 - base_url = "https://zcode.z.ai", + -- zcode 只存在 /api/v1/zcode-plan/anthropic/v1/messages 这一个 chat 路径 + -- (参考 zcode-anthropic.ts:7 注释:openai / 裸 v1/chat/completions 一律 + -- 404 page not found)。适配器 anthropic 补 /v1/messages。 + base_url = "https://zcode.z.ai/api/v1/zcode-plan/anthropic", + -- 远端模型目录(照参考 zcode-upstream.ts ZCODE_CLIENT_CONFIGS_URL) + models_remote = { + endpoints = { { url = "https://zcode.z.ai/api/v1/client/configs", method = "GET", + query = "app_version=1.0.0&platform=unknown" } }, + auth = "none", + parse = "zcode_configs", + }, endpoint = "/api/v1/zcode-plan/anthropic/v1/messages", adapter = "anthropic", -- 余额端点(需要 Authorization;照参考 ZCODE_BILLING_BALANCE_URL) @@ -554,7 +865,10 @@ local PLATFORMS = { return tonumber(d.balance or d.remaining or d.credits or d.total) end, }, - models = { "zcode-default" }, + -- 兜底目录(照参考 ZCODE_FALLBACK_MODELS)。远端可拉: + -- GET /api/v1/client/configs?app_version=...&platform=unknown + -- 此前写的 "zcode-default" 是我编的占位名,上游不认。 + models = { "GLM-5.3", "GLM-5.3-Flash" }, }, -- Loomy —— 微信扫码登录(照参考 loomy-oauth.ts 逆向)。 @@ -563,16 +877,16 @@ local PLATFORMS = { id = "loomy", name = "Loomy", vendor = "loomy", - -- ⚠️ 纠正第三处长期错误:此前标 manual,理由是"扫码在微信里完成,Lua 做不了"。 + -- 注意纠正第三处长期错误:此前标 manual,理由是"扫码在微信里完成,Lua 做不了"。 -- 扫码动作确实在用户手机上,但**协议全是纯 HTTP/Lua 可做**: - -- ① 取 uuid:GET open.weixin.qq.com/connect/qrconnect?...&state=... 从 HTML 抽 uuid - -- ② 二维码图:GET open.weixin.qq.com/connect/qrcode/{uuid}(JPEG,前端渲染) - -- ③ 长轮询:GET long.open.weixin.qq.com/connect/l/qrconnect?uuid&last&_ - -- 响应体里 wx_errcode=NNN / wx_code='...' - -- ④ 拿 wx_code 后调讯飞账号端点换凭据(HMAC-SHA1 签名,见 loomyAuthHeaders) + -- ① 取 uuid:GET open.weixin.qq.com/connect/qrconnect?...&state=... 从 HTML 抽 uuid + -- ② 二维码图:GET open.weixin.qq.com/connect/qrcode/{uuid}(JPEG,前端渲染) + -- ③ 长轮询:GET long.open.weixin.qq.com/connect/l/qrconnect?uuid&last&_ + -- 响应体里 wx_errcode=NNN / wx_code='...' + -- ④ 拿 wx_code 后调讯飞账号端点换凭据(HMAC-SHA1 签名,见 loomyAuthHeaders) -- 判据(参考点名;早期版本**读反了**导致"扫码后卡在已扫码"): - -- 408=等待扫码;404=已扫码待确认(继续);405=**已确认**(wx_code 在此帧); - -- 403=用户取消;402=二维码失效 + -- 408=等待扫码;404=已扫码待确认(继续);405=**已确认**(wx_code 在此帧); + -- 403=用户取消;402=二维码失效 flow = "wechat-poll", wechat = { -- appid / redirect_uri 照参考常量(原写的 appid 是我编的,上游不认); @@ -586,15 +900,23 @@ local PLATFORMS = { -- 逐字照参考 WECHAT_UA(微信对空 UA / 爬虫 UA 可能拒绝) user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" .. " (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - account_base = "https://account.xfyun.cn", + -- 参考真值 LOOMY_ACCOUNT_BASE。此前写的 account.xfyun.cn 是我编的 + -- (DNS 都不解析),实际账号域是 xfinfr.com —— 拼音近似导致的误写。 + account_base = "https://account.xfinfr.com", bind_path = "/login/thirdAccount/bind/auth", access_key_id = "2thryby66wxi53sk", access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r", app_id = "GM3LOOMY", }, - base_url = "https://loomy.xunfei.cn", + -- loomy 的 chat 路径是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494 + -- 实测标准 OpenAI 兼容)。 + base_url = "https://loomy.xunfei.cn/chat/completions", adapter = "openai", - models = { "loomy-default" }, + -- 兜底目录(照参考 LOOMY_FALLBACK_MODELS)。此前 "loomy-default" 是我编的。 + models = { + "deepseek-v4-flash-0731", "MiniMax-M3", "Kimi-k2.6", "qwen-3.8-max", + "GLM-5.3-Flash", "qwen3.8-flash", "spark-x", "mimo-v2.5", + }, }, -- Raccoon(商汤小浣熊)—— 扫码登录(照参考 raccoon-oauth.ts 逆向)。 @@ -603,11 +925,11 @@ local PLATFORMS = { id = "raccoon", name = "Raccoon(商汤小浣熊)", vendor = "sensetime", - -- ⚠️ 纠正第四处:此前标 manual,说"要扫码,做不了"。**扫码路径纯 HTTP 可做**: - -- ① 本地造 qrcode_code(16 字节随机 hex) - -- ② 二维码 URL {apiBase}/login/mp?code=...&appname=商汤小浣熊官网(前端渲染) - -- ③ 轮询 POST {apiBase}/api/web/auth/v1/login_with_qrcode_code {qrcode_code} - -- → data.status ∈ pending/logging/canceled/success;success 带 token + -- 注意纠正第四处:此前标 manual,说"要扫码,做不了"。**扫码路径纯 HTTP 可做**: + -- ① 本地造 qrcode_code(16 字节随机 hex) + -- ② 二维码 URL {apiBase}/login/mp?code=...&appname=商汤小浣熊官网(前端渲染) + -- ③ 轮询 POST {apiBase}/api/web/auth/v1/login_with_qrcode_code {qrcode_code} + -- → data.status ∈ pending/logging/canceled/success;success 带 token -- 参考注释:code 由**宿主侧本地随机生成**(服务端接受任意自造 code 并进入 -- pending),故完全绕开收不到的自定义协议回调。 -- 短信路径(/sms/send + /sms/verify)需要阿里云滑块 captcha_param 与手机号 @@ -622,9 +944,16 @@ local PLATFORMS = { interval = 2, }, -- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions) - base_url = "https://xiaohuanxiong.com/api/web/llm/v2", + -- raccoon 的 chat 端点是 /api/web/llm/v2/chat/completions。 + base_url = "https://xiaohuanxiong.com/api/web/llm/v2/chat/completions", adapter = "openai", - models = { "raccoon-default" }, + -- 兜底目录(照参考 RACCOON_FALLBACK_MODELS)。此前 "raccoon-default" 是我编的。 + -- 注意 sn- 前缀是本平台的真实命名约定(sensenova 系列)。 + models = { + "sn-sensenova-6-8-flash", "sn-sensenova-6-8-flash-lite", + "sn-glm-5-3", "sn-kimi-k3", "sn-glm-5-3-flash", + "sn-deepseek-v4-1-flash", + }, -- 余额(照参考:GET {apiBase}/api/web/points/v1/balance) balance = { url = "https://xiaohuanxiong.com/api/web/points/v1/balance", @@ -648,44 +977,971 @@ local PLATFORMS = { }, } --- CodeArts 的 SDK-HMAC-SHA256 签到钩子(照参考 codearts-credits.ts 逆向)。 --- 绑在 PLATFORMS 表定义之后:claim_daily 读取 plat.claim_fn,checkin 表驱动 --- 不动签名请求(参考注释也点明这一点),所以用钩子生成签名后的请求形状。 -do - local ca - for _, p in ipairs(PLATFORMS) do - if p.id == "codearts" then ca = p break end +-- 华为云 snap-access 引擎域名(连接与签名都用它)。 +local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com" + +-- CodeArts(华为云 snap-access)的请求签名与每日领取。 +-- +-- 逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`。 +-- 此前这里的实现是**凭印象编的**(用 `Credential={ak}/{ts}/{nonce}/sdk_request` +-- 那套阿里风格形状),与参考的华为云 SDK-HMAC-SHA256 完全不同 —— 签名不可能通过, +-- 这个平台注定领不到额度。对齐要点: +-- 1. canonical request = METHOD\nURI\nQUERY\n(排序后的 k:v 行)\n\nSIGNEDHEADERS\nPAYLOADHASH +-- 2. string to sign = "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}" +-- 3. signature = HMAC-SHA256(sk, stringToSign) +-- 4. Authorization = "SDK-HMAC-SHA256 Access={ak},SignedHeaders=...,Signature=..." +-- (**是 Access= 不是 Credential=**) +-- 5. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token +-- (+ 非 GET 时的 content-type);按字典序拼 SignedHeaders。 +-- 6. Agent-Type / X-Language 是**签名后**追加的,绝不能进 canonical(否则验签失败)。 +-- 7. URI 末尾必须补 '/'。 +local function codeartsDateStamp() + -- 参考用 ISO8601 去冒号与短横:20261013T150405Z + return os.date("!%Y%m%dT%H%M%SZ") +end + +-- codeartsSignedRequest(method, url, cred, body):返回可直接发出的 {url,method,headers,body}。 +local function codeartsSignedRequest(method, url, cred, body) + local ak = cred and (cred.access_key_id or cred.access_key) or "" + local sk = cred and (cred.secret_access_key or cred.secret_key) or "" + local st = cred and (cred.security_token or "") or "" + if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end + local host = url:match("^https?://([^/]+)") + local uri = url:match("^https?://[^/]+(/[^?]*)") or "/" + if uri:sub(-1) ~= "/" then uri = uri .. "/" end + local query = url:match("%?([^#]*)$") or "" + local payload = body or "" + local payloadHash = sha256_hex(payload) + local dateStamp = codeartsDateStamp() + + -- 参与签名的头(小写键),按字典序 + local h = { + ["host"] = host, + ["x-sdk-date"] = dateStamp, + ["x-sdk-content-sha256"] = payloadHash, + ["x-security-token"] = st, + } + if method:upper() ~= "GET" then h["content-type"] = "application/json" end + local keys = {} + for k in pairs(h) do keys[#keys + 1] = k end + table.sort(keys) + local lines, signedNames = {}, {} + for _, k in ipairs(keys) do + lines[#lines + 1] = k .. ":" .. h[k] + signedNames[#signedNames + 1] = k end - if ca then - -- SDK-HMAC-SHA256 派生签名密钥链(照参考 canonical request 形状)。 - ca.claim_fn = function(plat, tok, args) - local url = plat.checkin.url - local method = plat.checkin.method or "GET" - local host = url:match("^https?://([^/:]+)") - local path = url:match("^https?://[^/]+(/[^?]*)") - local ts = tostring(os.time and os.time() or 0) - local nonce = tostring(math.random(1e12)) - local canonical = method .. "\n" .. (path or "/") .. "\n" .. ts .. "\n" .. nonce - local secret = tok or "" - local kDate = hmac_sha256_hex("SDK" .. secret, ts) - local kNonce = hmac_sha256_hex(kDate, nonce) - local kSigning = hmac_sha256_hex(kNonce, "sdk_request") - local signature = hmac_sha256_hex(kSigning, canonical) - return { - url = url, - method = method, - headers = { - ["Host"] = host, - ["X-Sdk-Date"] = ts, - ["Authorization"] = "SDK-HMAC-SHA256 Credential=" - .. (args and args.access_key or "") .. "/" .. ts .. "/" .. nonce - .. "/sdk_request, SignedHeaders=host, Signature=" .. signature, - }, + local signedHeaders = table.concat(signedNames, ";") + local canonical = table.concat({ + method:upper(), uri, query, table.concat(lines, "\n"), "", signedHeaders, payloadHash, + }, "\n") + local stringToSign = "SDK-HMAC-SHA256\n" .. dateStamp .. "\n" .. sha256_hex(canonical) + local signature = hmac_sha256_hex(sk, stringToSign) + + -- 把中间值一并带出:判据必须比对**真实函数算出的** canonical / string-to-sign, + -- 而不是自己重算一遍 —— 否则真实签名被改坏时判据照样通过(判据与被测对象脱节, + -- 实测踩过:去掉 content-type 参与签名,探针重算版本仍绿)。 + -- 发出去的头:签名头 + Authorization(host 由 HTTP 栈生成,不手填) + local out = {} + for _, k in ipairs(keys) do + if k ~= "host" then out[k] = h[k] end + end + out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak + .. ",SignedHeaders=" .. signedHeaders .. ",Signature=" .. signature + -- 签名后追加(不参与 canonical) + out["Agent-Type"] = "PromptCenter" + out["X-Language"] = "zh-cn" + return { url = url, method = method:upper(), headers = out, body = payload, + canonical = canonical, string_to_sign = stringToSign, + signed_headers = signedHeaders } +end + +-- codeartsAccountInfo(cred):账号信息(statistics/plugin),判断是否积分账户。 +local function codeartsAccountInfo(cred) + local req, err = codeartsSignedRequest("GET", CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil) + if not req then return nil, err end + local resp, herr = __llmsproxy_http(req) + if herr or not resp or resp.status ~= 200 then + return nil, "账户信息查询失败: " .. tostring(herr or (resp and resp.status)) + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil, "账户信息响应无法解析" end + local d = rec.data or rec + local metrics = d.metrics or {} + local total = nil + for _, m in ipairs(metrics) do + -- IDE 从 usageTotalPackageCredit 读总额(参考 codearts-credits.ts 注释) + if type(m) == "table" and m.name == "usageTotalPackageCredit" then + total = tonumber(m.value) or tonumber(m.credit) or total + end + end + local billing = d.billingMode or d.billing_mode or d.chargingMode or "" + return { + isCreditPackage = tostring(billing):upper():find("CREDIT") ~= nil, + isTokenPackage = tostring(billing):upper():find("TOKEN") ~= nil, + total = total, + raw = d, + }, nil +end + +-- codeartsClaim(plat, tok, args):完整领取流程(照参考的 6 步判定顺序)。 +-- 返回 (requestSpec, nil) 给通用发送路径,或 (nil, err) 提前失败。 +-- 注意:参考的流程要发**多个**请求(账户→活动→claim→confirm),而 claim_daily +-- 的契约只发一个。故这里把整段流程跑完并直接落结果,返回一个「不发送」的哨兵, +-- 由 claim_daily 识别 —— 见 CLAIM_DONE 约定。 +local function codeartsClaim(plat, tok, args) + local cred = (args and args.credential) or nil + if type(cred) ~= "table" then return nil, "缺少凭据对象(AK/SK 需要结构化字段)" end + + -- ① 账户类型:非积分账户 → inactive(Token 账户不该被报成"领取失败") + local info, ierr = codeartsAccountInfo(cred) + if not info then return nil, ierr end + if not info.isCreditPackage then + return { done = true, kind = "inactive", + message = info.isTokenPackage and "Token 计费账户,不在积分活动范围" + or "非积分计费账户,不在积分活动范围" } + end + + -- ② 活动列表 + local areq = codeartsSignedRequest("GET", + CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil) + if not areq then return nil, "活动列表请求构造失败" end + local aresp, aerr = __llmsproxy_http(areq) + if aerr or not aresp or aresp.status ~= 200 then + return { done = true, kind = "failed", + message = "活动列表查询失败: " .. tostring(aerr or (aresp and aresp.status)) } + end + local aok, arec = pcall(json.decode, aresp.body or "") + local items = aok and type(arec) == "table" and ((arec.data or {}).items) or nil + if type(items) ~= "table" then + return { done = true, kind = "failed", message = "活动列表响应缺少 items" } + end + local act = nil + for _, it in ipairs(items) do + if type(it) == "table" and (it.type or "") == "USER_LOGIN" then act = it break end + end + if not act then + return { done = true, kind = "inactive", message = "未找到每日签到活动" } + end + + -- ③ 幂等保护:本协议没有幂等键,预检不可省 + if act.claimable ~= true then + local st = tostring(act.status or "") + -- 已领取态(参考 CLAIMED_STATUSES) + local claimed = st == "CLAIMED" or st == "RECEIVED" or st == "COMPLETED" + return { done = true, kind = claimed and "already-claimed" or "inactive", + message = claimed and "今天已领取" or ("当前不可领取(status=" .. st .. ")") } + end + local campaignId = act.campaignId or act.campaign_id or "" + if campaignId == "" then + return { done = true, kind = "failed", message = "活动缺少 campaignId,无法领取" } + end + + -- ④ claim + local body = json.encode({ campaignId = campaignId, channel = "IDE" }) + local creq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/claim", cred, body) + if not creq then return nil, "claim 请求构造失败" end + local cresp, cerr = __llmsproxy_http(creq) + if cerr or not cresp or cresp.status ~= 200 then + return { done = true, kind = "failed", + message = "领取失败: " .. tostring(cerr or (cresp and cresp.status)) } + end + + -- ⑤ 服务端要求确认时补 confirm(判据 benefit.id 非空);confirm 失败不算整体失败 + local msg = "已领取" + local cok, crec = pcall(json.decode, cresp.body or "") + local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil + if type(benefit) == "table" and benefit.id then + local fbody = json.encode({ id = benefit.id, channel = "IDE" }) + local freq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody) + if freq then + local fresp, ferr = __llmsproxy_http(freq) + -- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态) + if ferr or not fresp or fresp.status ~= 200 then + msg = "已领取(确认步骤失败,可能停在待确认)" + end + end + end + return { done = true, kind = "success", message = msg } +end + +-- ---- 基础编码原语(必须在**所有使用者之前**定义)---- +-- ⚠️ 本文件反复踩「local 前向引用」:local function 只从定义处向后可见, +-- 更早的代码引用它就是 nil。这三个被 CodeArts 的 PKCE/DPoP 通过 base64url +-- 与 hex 互转调用,必须放在 CodeArts 块之前。 +local function hexToBin(h) + return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end)) +end + +local function b64url(s) + return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", "")) +end + +-- b64urlRaw:无填充 base64url(JWS/JWK 用) +local function b64urlRaw(bin) + local e = base64_encode(bin) + return (e:gsub("+", "-"):gsub("/", "_"):gsub("=", "")) +end + +-- ============================================================================ +-- CodeArts(华为)登录:新式 IAM OAuth(PKCE + DPoP ES256) +-- ============================================================================ +-- 逐段对齐参考 login.ts:runOAuthFlow + oauth.ts + channel-pack-rpc.ts 的 +-- `codearts.startLogin()`。此前我把它当成"手填凭据"(paste 流),**是错的**: +-- 参考是完整 OAuth —— 起本地回调服务器(端口须 ≥10000)、打开 portal 授权页、 +-- 收到 code 后走华为 STS 端点换 AK/SK/securityToken(带 DPoP 证明)。 +-- +-- 端点与常量(照抄): +-- PORTAL_AUTHORIZE_BASE = https://codearts.huaweicloud.com/portal/authorize +-- STS_TOKEN_ENDPOINT = https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens +-- CLIENT_ID = codearts-agent(也是 uri_scheme) +-- REDIRECT_PATH = /oauth/callback +-- LOGIN_PLUGIN_NAME = snap_AIIDE LOGIN_PLUGIN_VERSION = 5.2.0 +-- OAUTH_THEME = 2(Dark) OAUTH_LOCALE = zh-cn +-- code_challenge_method = **SHA-256**(不是 S256!portal 以此识别 OAuth, +-- 错值会回退到旧 ticket 流程) +-- 旧流程回退(ticket):CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket +local CODEARTS_PORTAL_AUTHORIZE = "https://codearts.huaweicloud.com/portal/authorize" +local CODEARTS_STS_TOKEN = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens" +local CODEARTS_CLIENT_ID = "codearts-agent" +local CODEARTS_REDIRECT_PATH = "/oauth/callback" +local CODEARTS_LOGIN_PLUGIN_NAME = "snap_AIIDE" +local CODEARTS_LOGIN_PLUGIN_VERSION = "5.2.0" +local CODEARTS_OAUTH_THEME = "2" +local CODEARTS_OAUTH_LOCALE = "zh-cn" + +-- ES256 JWS 签名(照参考 signDpopJws): +-- header = {alg:"ES256", typ:"dpop+jwt", jwk:{kty,crv,x,y}} +-- payload = {htm, htu, iat, jti(32字节hex)} +-- sig = ES256(privKey, base64url(header) .. "." .. base64url(payload)) +-- ⚠️ JWS 的 ES256 签名是 **raw r||s(各 32 字节)**,而 openssl 输出的是 +-- DER(SEQUENCE{INTEGER r, INTEGER s})。必须转格式,否则服务端验签失败。 +-- 用 openssl 签:把私钥写成 PEM,签名,再把 DER 转 raw。 +local function codeartsDpopJws(key, htm, htu) + -- 1) 组 header/payload + local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public }) + local payload = json.encode({ htm = htm, htu = htu, + iat = (os.time and os.time() or 0), + jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12))) + .. sha256_hex(tostring(math.random(1e12))):sub(1, 32) }) + local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload) + -- 2) openssl 签(PEM 私钥从 JWK 的 d 与 x/y 重建) + local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12)) + os.execute("mkdir -p " .. dir .. " 2>/dev/null") + local pem = dir .. "/k.pem" + local dh = hexToBin((key.private.d:gsub("-", "+"):gsub("_", "/") .. + string.rep("=", (4 - #key.private.d % 4) % 4))) + -- 用 openssl 的 ecparam -genkey 无法指定 d;改用 asn1 构造太脆。 + -- 改为:生成新私钥后**用它的 d** 组 JWK(调用方须用同一把)。见 codeartsNewDpop。 + os.execute("rm -rf " .. dir) + return nil, "内部错误:DPoP 签名需与密钥同时生成" +end + +-- codeartsNewDpop():生成密钥对**并返回可签名的 PEM**(两者必须同源 —— +-- 公钥 JWK 必须来自签 JWS 的那把私钥,否则服务端按 header.jwk 验签会失败)。 +-- ⚠️ 两个本机实测的坑(都已踩): +-- 1. **本机没有 xxd**(`command -v xxd` 为空)→ 用它取十六进制会静默得到 +-- 空串。改用 `od -An -tx1`。 +-- 2. `openssl ec -text` 打印 32 字节 priv 时**会截断**(实测只给 30 字节)→ +-- `d` 必须用 `openssl asn1parse` 的 `[HEX DUMP]`(精确 32 字节); +-- 而 `tail -c 32` 拿到的是 SEC1 结构里**公钥 y**,不是 d。 +-- 3. 公钥点用 `openssl ec -text` 的 pub 段(完整 04||x||y,130 hex), +-- 与 `-pubout` DER 末尾 65 字节交叉一致。 +local function codeartsNewDpop() + local dir = "/tmp/llmsproxy-codeartsdpop-" .. tostring(math.random(1e12)) + os.execute("mkdir -p " .. dir .. " 2>/dev/null") + local pem = dir .. "/k.pem" + os.execute("openssl ecparam -name prime256v1 -genkey -noout -out " .. pem .. " 2>/dev/null") + local f = io.open(pem, "r") + if not f then os.execute("rm -rf " .. dir) return nil, "openssl EC 生成失败" end + local pemTxt = f:read("*a") + f:close() + -- d:asn1parse 的 OCTET STRING(权威、精确) + local dh = io.popen("openssl asn1parse -in " .. pem + .. " 2>/dev/null | grep -m1 'OCTET STRING' | awk -F': ' '{print $NF}'") + local dHex = dh and (dh:read("*a") or ""):gsub("%s", "") or "" + if dh then dh:close() end + -- 公钥点:ec -text 的 pub 段 + local ph = io.popen("openssl ec -in " .. pem + .. " -text -noout 2>/dev/null | awk '/^pub:/{f=1;next} /^ASN1/{f=0} f' | tr -d ' :' | tr -d '\n'") + local pubHex = ph and (ph:read("*a") or ""):gsub("%s", "") or "" + if ph then ph:close() end + if #pubHex < 130 then + -- 兜底:-pubout DER 的末尾 65 字节(未压缩点) + local pf = io.popen("openssl ec -in " .. pem + .. " -pubout -outform DER 2>/dev/null | tail -c 65 | od -An -tx1 | tr -d ' \n'") + pubHex = pf and (pf:read("*a") or ""):gsub("%s", "") or "" + if pf then pf:close() end + end + if #dHex < 64 or #pubHex < 130 then + os.execute("rm -rf " .. dir) + return nil, "无法解析 EC 密钥(d=" .. #dHex .. " pub=" .. #pubHex .. ")" + end + local x = pubHex:sub(3, 66) + local y = pubHex:sub(67, 130) + local key = { + pem = pemTxt, + public = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)), y = b64urlRaw(hexToBin(y)) }, + private = { kty = "EC", crv = "P-256", x = b64urlRaw(hexToBin(x)), + y = b64urlRaw(hexToBin(y)), d = b64urlRaw(hexToBin(dHex:sub(-64))) }, + } + os.execute("rm -rf " .. dir) + return key, nil +end + +-- DER(EC signature) → raw r||s(各 32 字节)。JWS ES256 必须是这个格式。 +local function codeartsDerToRawSig(der) + -- SEQUENCE(0x30 len) INTEGER(0x02 rlen r) INTEGER(0x02 slen s) + local i = 1 + if der:byte(i) ~= 0x30 then return nil end + i = i + 1 + local l = der:byte(i) + if l and l > 0x80 then i = i + (l - 0x80) end + i = i + 1 + if der:byte(i) ~= 0x02 then return nil end + i = i + 1 + local rlen = der:byte(i); i = i + 1 + local r = der:sub(i, i + rlen - 1); i = i + rlen + if der:byte(i) ~= 0x02 then return nil end + i = i + 1 + local slen = der:byte(i); i = i + 1 + local s = der:sub(i, i + slen - 1) + local function pad(v) + -- 去掉前导 0x00(DER 的正数填充),再左侧补零到 32 字节 + while #v > 32 and v:byte(1) == 0 do v = v:sub(2) end + if #v < 32 then v = string.rep("\0", 32 - #v) .. v end + return v + end + return pad(r) .. pad(s) +end + +-- codeartsDpopSign(key, htm, htu):产出 dpop+jwt(走真实 openssl 签名)。 +local function codeartsDpopSign(key, htm, htu) + local header = json.encode({ alg = "ES256", typ = "dpop+jwt", jwk = key.public }) + local payload = json.encode({ htm = htm, htu = htu, + iat = (os.time and os.time() or 0), + jti = sha256_hex(tostring(os.time()) .. tostring(math.random(1e12))) + .. sha256_hex(tostring(math.random(1e12))):sub(1, 32) }) + local signingInput = b64urlRaw(header) .. "." .. b64urlRaw(payload) + local dir = "/tmp/llmsproxy-dpopsig-" .. tostring(math.random(1e12)) + os.execute("mkdir -p " .. dir .. " 2>/dev/null") + local pemPath, inPath = dir .. "/k.pem", dir .. "/in" + local pf = io.open(pemPath, "w") + if not pf then os.execute("rm -rf " .. dir) return nil, "无法写临时 PEM" end + pf:write(key.pem) + pf:close() + local inf = io.open(inPath, "w") + if not inf then os.execute("rm -rf " .. dir) return nil, "无法写签名输入" end + inf:write(signingInput) + inf:close() + local h = io.popen("openssl dgst -sha256 -sign " .. pemPath .. " " .. inPath + .. " 2>/dev/null | od -An -tx1 | tr -d ' \n'") + local derHex = h and (h:read("*a") or ""):gsub("%s", "") or "" + if h then h:close() end + os.execute("rm -rf " .. dir) + if derHex == "" then return nil, "openssl 签名失败" end + local raw = codeartsDerToRawSig(hexToBin(derHex)) + if not raw then return nil, "DER→raw 转换失败" end + return signingInput .. "." .. b64urlRaw(raw), nil +end + +-- codeartsRequestToken(body, key):向华为 STS 端点带 DPoP 换 token。 +-- 照参考 requestToken:POST 表单 + DPoP 头;响应 credentials{access_key_id, +-- secret_access_key, security_token, expiration} + refresh_token。 +-- 终态判定只认两件事(照参考):`invalid_grant` 或 error_code 含 +-- `ExpiredRefreshToken`;**不认 InvalidDPoPHeader**(那是一次请求层面的拒绝, +-- 时钟偏差/重放/抖动都会触发,当终态会把完好账号一步标死)。 +local function codeartsRequestToken(body, key) + local dpop, derr = codeartsDpopSign(key, "POST", CODEARTS_STS_TOKEN) + if not dpop then return nil, derr end + local parts = {} + for k, v in pairs(body) do + parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v) + end + local resp, err = __llmsproxy_http({ + url = CODEARTS_STS_TOKEN, method = "POST", + headers = { ["DPoP"] = dpop, ["Content-Type"] = "application/x-www-form-urlencoded" }, + body = table.concat(parts, "&"), + }) + if err then return nil, "network error: " .. tostring(err) end + if not resp then return nil, "无响应" end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then rec = nil end + if resp.status ~= 200 or type(rec) ~= "table" or type(rec.credentials) ~= "table" then + local code = tostring(rec and rec.error_code or "") + local emsg = tostring(rec and (rec.error_msg or rec.error) or "") + local terminal = (rec and rec.error == "invalid_grant") + or code:find("ExpiredRefreshToken", 1, true) ~= nil + return nil, "token 请求失败: HTTP " .. tostring(resp.status) .. " " .. code .. " " .. emsg, + terminal + end + return rec, nil +end + +-- codeartsCredentialFrom(token, pkce, key):组装持久化凭据(照参考 +-- credentialFromTokenResponse:含 refresh 所需的 code_verifier 与 DPoP 私钥 JWK)。 +local function codeartsCredentialFrom(token, pkce, key) + local c = token.credentials or {} + return { + access_key_id = c.access_key_id or "", + secret_access_key = c.secret_access_key or "", + security_token = c.security_token or "", + expires_at = c.expiration or "", + refresh_token = token.refresh_token, + code_verifier = pkce.verifier, + dpop_private_key_jwk = key.private, + } +end + +-- buildCodeartsAuthorizeUrl(port, pkce, ticketId):照参考 buildOAuthLoginUrl 逐字拼。 +-- ⚠️ 三处易错(参考注释都点名了): +-- 1. `code_challenge_method=SHA-256` —— 不是 RFC 的 S256!portal 以此识别 +-- OAuth 授权,错值会**回退到旧 ticket 流程**(看起来"也能用",但不是我们要的)。 +-- 2. 参数里**不得**出现 `auth_callback_url` —— portal 仅凭 port 参数构造回调, +-- 多余的回调地址会被视为异常并回退旧流程。 +-- 3. 端口必须 ≥10000。 +local function buildCodeartsAuthorizeUrl(port, pkce, ticketId) + return CODEARTS_PORTAL_AUTHORIZE + .. "?theme=" .. CODEARTS_OAUTH_THEME + .. "&locale=" .. CODEARTS_OAUTH_LOCALE + .. "&uri_scheme=" .. CODEARTS_CLIENT_ID + .. "&client_id=" .. CODEARTS_CLIENT_ID + .. "&port=" .. tostring(port) + .. "&code_challenge=" .. pkce.challenge + .. "&code_challenge_method=SHA-256" + .. "&ticket_id=" .. rfc3986Escape(ticketId) + .. "&plugin-name=" .. CODEARTS_LOGIN_PLUGIN_NAME + .. "&plugin-version=" .. CODEARTS_LOGIN_PLUGIN_VERSION +end + +-- codeartsTicketPoll(ticketId, secret):旧流程回退时轮询 ticket 换凭据。 +-- 照参考 pollForCredential:GET ?ticket_id&secret,头带 plugin-name/version, +-- 最多 120 次、每次间隔 1s,瞬时失败跳过。 +local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion) + local url = CODEARTS_CREDENTIAL_ENDPOINT .. "?ticket_id=" .. rfc3986Escape(ticketId) + .. "&secret=" .. rfc3986Escape(secret) + for _ = 1, 120 do + local resp = __llmsproxy_http({ + url = url, method = "GET", + headers = { ["Content-Type"] = "application/json;charset=UTF-8", + ["plugin-name"] = pluginName or CODEARTS_TICKET_PLUGIN_NAME, + ["plugin-version"] = pluginVersion or CODEARTS_TICKET_PLUGIN_VERSION }, + }) + if resp and resp.status == 200 then + local ok, rec = pcall(json.decode, resp.body or "") + if ok and type(rec) == "table" then + -- 两种形状(照参考 parseCredentialResponse) + local c = rec.credential + if type(c) == "table" and c.access and (c.securitytoken or c.securityToken) then + return { access_key_id = c.access, secret_access_key = c.secret or "", + security_token = c.securitytoken or c.securityToken, + expires_at = c.expires_at or c.expiresAt or "", + domain_id = rec.domain_id or "", user_id = rec.user_id or "", + user_name = rec.user_name or "" } + end + local r = rec.result + if type(r) == "table" and r.accessKeyId and r.securityToken then + return { access_key_id = r.accessKeyId, secret_access_key = r.secretAccessKey or "", + security_token = r.securityToken, + expires_at = r.expiration or r.expiresAt or "" } + end + end + end + -- 参考的 sleep(1000);单 VM 里不能长阻塞,故用较短的等待并显式让出 + os.execute("sleep 1") + end + return nil, "CodeArts 登录超时" +end + +-- 旧 ticket 流程(portal 判定新式不可用时回退) +local CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect" +local HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html" +local CODEARTS_CREDENTIAL_ENDPOINT = + "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket" +local CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains" +local CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3" + +-- 生成 PKCE 配对(照参考 generatePkcePair:verifier = 48 随机字节的 base64url, +-- challenge = sha256(verifier) 的 base64url)。 +-- 48 字节 base64url = 64 字符,符合 RFC 7636 的 43..128。 +local function codeartsPkcePair() + -- 用 openssl 取 48 字节随机(Lua 的 math.random 熵不够) + local h = io.popen("openssl rand 48 2>/dev/null | base64 -w0") + local raw = h and h:read("*a") or "" + if h then h:close() end + raw = (raw:gsub("%s", "")) + local verifier = (raw:gsub("+", "-"):gsub("/", "_"):gsub("=", "")) + if #verifier < 43 then + return nil, "无法生成 PKCE verifier" + end + local challenge = b64urlRaw(hexToBin(sha256_hex(verifier))) + return { verifier = verifier, challenge = challenge } +end + +-- ---- Qoder 每日活动领取(照参考 qoder-credits.ts 逐行移植)---- +-- +-- 参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在此钉死: +-- ① 「无可领活动」必须报 inactive,**不能**报 already-claimed。 +-- 旧实现 targets 为空就说"今天已领取" ⇒ 只要服务端没下发可领项 +-- (含请求头不完整 / 未到刷新时间 / 本就无活动)界面就谎报已领, +-- 与官方 IDE 的"可领取"直接矛盾(用户报障原文: +-- 「没领过却显示今日已领取,去 IDE 看还能领」)。 +-- ② 活动每日 **10:00(UTC+8)**刷新。刷新**之前**看到的 CLAIMED 属于 +-- **昨天** —— 此时说"今天已领取"是谎报,且不可逆(用户以为今天不必再领)。 +-- 必须用**算术平移**判 UTC+8,不能用本机时区(出差/改时区会判错)。 +-- ③ 未开通的账号要给出**可操作**提示(去官方客户端登录一次), +-- 而不是含糊的"没有可领取的活动"。 +-- 另:一个账号可能同时有**多个** CLAIM_BENEFIT 活动,逐个领取而非只领第一个。 + +-- 可被 set_endpoints 覆盖(测试指到假上游用)。 +-- 可被 set_endpoints 覆盖(测试指到假上游用)。 +QODER_SASH_BASE = "https://openapi.qoder.sh" + +-- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。 +local QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000 +local QODER_REFRESH_HOUR_UTC8 = 10 + +-- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。 +local function qoderCampaignRefreshed(nowMs) + -- nowMs 可注入(判据要覆盖"刷新前/刷新后"两个方向;本机恰是 UTC+8, + -- 只用真实时刻会让「用本机时区判」这个变异在这台机上不可判定 —— 判据不可达)。 + nowMs = nowMs or ((os.time and os.time() or 0) * 1000) + -- **必须用算术平移**判 UTC+8,不能用 os.date("*t")(本机时区): + -- 活动按 UTC+8 结算,取本机时区在出差/改时区时会判错方向 + -- (偏东提前把当天记为已处理、真漏领;偏西一天判两次)。 + local utc8 = os.date("!*t", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000)) + return (utc8.hour or 0) >= QODER_REFRESH_HOUR_UTC8 +end + +-- 读数字/字符串(容忍字符串形式与缺失),照参考 readNumber/readString +local function qReadNum(src, key) + if type(src) ~= "table" then return nil end + local v = src[key] + if type(v) == "number" then return v end + if type(v) == "string" and v ~= "" then return tonumber(v) end + return nil +end +local function qReadStr(src, key) + if type(src) ~= "table" then return nil end + local v = src[key] + if type(v) == "string" and v ~= "" then return v end + return nil +end + +-- parseQoderCampaigns:解析活动列表响应 +local function qoderParseCampaigns(body) + if type(body) ~= "table" or type(body.campaigns) ~= "table" then + -- 形状非法时仍可取 showCampaign/claimable(参考对非数组容忍) + if type(body) ~= "table" then return nil end + end + local out = { campaigns = {}, claimable = body.claimable == true, showCampaign = body.showCampaign == true } + for _, it in ipairs(body.campaigns or {}) do + local id = qReadStr(it, "campaignId") + if id then + out.campaigns[#out.campaigns + 1] = { + campaignId = id, + actionType = qReadStr(it, "actionType"), + claimStatus = qReadStr(it, "claimStatus"), + amount = qReadNum(it.benefit, "amount"), } end end + return out end +-- qoderLoadCampaigns(plat, cred):拉活动列表,返回 (parsed, nil) 或 (nil, err) +local function qoderLoadCampaigns(plat, cred) + local hdrs = nil + if plat.headers_fn then + local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred) + if okh and type(h) == "table" then hdrs = h end + end + if not hdrs then + hdrs = { ["Authorization"] = "Bearer " .. tostring(cred.access_token or ""), + ["Accept"] = "application/json", ["Cosy-ClientType"] = "10", ["User-Agent"] = "Qoder" } + end + local resp, err = __llmsproxy_http({ + url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns", method = "GET", headers = hdrs, + }) + if err then return nil, "活动列表查询失败: " .. tostring(err) end + if not resp or resp.status ~= 200 then + return nil, "活动列表查询失败: HTTP " .. tostring(resp and resp.status) + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil, "活动列表响应无法解析" end + return qoderParseCampaigns(rec), nil +end + +-- qoderUsageRaw(plat, cred):取用量原始响应(判"未开通"用) +local function qoderUsageRaw(plat, cred) + local hdrs = nil + if plat.headers_fn then + local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred) + if okh and type(h) == "table" then hdrs = h end + end + local resp = nil + if hdrs then + resp, _ = __llmsproxy_http({ url = QODER_SASH_BASE .. "/sash/api/v2/me/usage", method = "GET", headers = hdrs }) + end + if not resp or resp.status ~= 200 then return nil end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil end + return rec +end + +-- qoderClaimCampaign(plat, cred, campaignId):领取单个活动。 +-- ⚠️ 幂等判据是响应体的 `replayed`,**不是** HTTP 状态码:重复领取同样回 200, +-- 但 replayed=true 且不含 benefit(只看状态码会把"今天已领"误报成"成功 +100")。 +-- ⚠️ 请求体必须是**空串**(抓包实测 content-length: 0)。 +local function qoderClaimCampaign(plat, cred, campaignId) + local hdrs = nil + if plat.headers_fn then + local okh, h = pcall(plat.headers_fn, tostring(cred.access_token or cred.accessToken or ""), cred) + if okh and type(h) == "table" then hdrs = h end + end + if not hdrs then hdrs = { ["Cosy-ClientType"] = "10" } end + hdrs["Content-Type"] = "application/json" + local resp, err = __llmsproxy_http({ + url = QODER_SASH_BASE .. "/sash/api/v1/me/campaigns/" + .. rfc3986Escape(campaignId) .. "/claim", + method = "POST", headers = hdrs, body = "", + }) + if err then return { kind = "failed", message = tostring(err) } end + if not resp or resp.status ~= 200 then + return { kind = "failed", message = "领取失败: HTTP " .. tostring(resp and resp.status) } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return { kind = "failed", message = "领取响应无法解析" } end + if rec.replayed == true then return { kind = "already-claimed", message = "今天已领取" } end + local st = rec.status + if st ~= nil and st ~= "CLAIMED" then + return { kind = "failed", message = "领取未成功(status=" .. tostring(st) .. ")" } + end + local amount = qReadNum(rec, "amount") or qReadNum(rec.benefit, "amount") or 0 + return { kind = "claimed", amount = amount } +end + +-- qoderClaim(plat, tok, args):完整活动领取(多活动逐个领)。 +local function qoderClaim(plat, tok, args) + local cred = (args and args.credential) or nil + if type(cred) ~= "table" then return nil, "缺少凭据对象" end + local parsed, lerr = qoderLoadCampaigns(plat, cred) + if not parsed then return { done = true, kind = "failed", message = lerr } end + + local refreshed = qoderCampaignRefreshed() + local targets = {} + for _, c in ipairs(parsed.campaigns) do + if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMABLE" then + targets[#targets + 1] = c + end + end + + if #targets == 0 then + -- ① 领过?—— 但**仅限刷新之后**(刷新前那条 CLAIMED 属于昨天) + local claimedBefore = false + for _, c in ipairs(parsed.campaigns) do + if c.actionType == "CLAIM_BENEFIT" and c.claimStatus == "CLAIMED" then claimedBefore = true break end + end + if claimedBefore then + if not refreshed then + return { done = true, kind = "inactive", + message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新)," + .. "当前看到的是昨天那一轮,请稍后再来领取。" } + end + return { done = true, kind = "already-claimed", message = "今天已领取" } + end + -- ② 未开通?(两条判据同时满足,避免把"正在刷新"误报成未开通) + local usage = qoderUsageRaw(plat, cred) + local hasBenefit = false + for _, c in ipairs(parsed.campaigns) do + if c.actionType == "CLAIM_BENEFIT" then hasBenefit = true break end + end + local addOnMissing = false + if type(usage) == "table" then + -- ⚠️ 判「字段是否存在」而非「remaining 是否为 0」: + -- 后者对"额度用光"与"从未开通"不可区分。 + local d = type(usage.data) == "table" and usage.data or usage + addOnMissing = (d.addOnQuota == nil) + end + if (not hasBenefit) and addOnMissing then + return { done = true, kind = "inactive", action_required = true, + message = "该账号尚未在 Qoder 侧开通每日领取(每日 100 Credits)。" + .. "请先用 Qoder 官方客户端登录一次该账号,开通后再回来领取。" } + end + return { done = true, kind = "inactive", message = "当前没有可领取的活动" } + end + + -- 逐个领取 + local total, firstErr, anyClaimed, allReplayed = 0, nil, false, true + for _, t in ipairs(targets) do + local r = qoderClaimCampaign(plat, cred, t.campaignId) + if r.kind == "claimed" then + anyClaimed = true + allReplayed = false + total = total + (tonumber(r.amount) or 0) + elseif r.kind == "already-claimed" then + -- 保持 allReplayed(这个活动此前已领) + else + allReplayed = false + if not firstErr then firstErr = r.message end + end + end + if anyClaimed then + return { done = true, kind = "success", message = "已领取 " .. tostring(total) .. " Credits" } + end + if allReplayed then + return { done = true, kind = "already-claimed", message = "今天已领取" } + end + return { done = true, kind = "failed", message = firstErr or "领取失败" } +end + +-- ---- 有道 LobsterAI 每日活动领取(照参考 lobsterai-credits.ts 逐行移植)---- +-- +-- 三步流程,判定顺序把「业务正常状态」与「真失败」严格分开: +-- ① 槽位查询失败 → failed;slotState≠available 或无 activityCode → inactive +-- ② 上下文查询失败 → failed;claimedToday → already-claimed; +-- actions 不含 check_in → inactive +-- ③ POST .../{activityCode}/actions/check_in(带客户端幂等键)→ 成功 +-- 参考注释特别强调:无可用活动是**正常业务状态**,不能显示成"领取失败"。 +-- ---- Qoder 设备身份(Cosy-MachineToken / Cosy-MachineType)---- +-- +-- 逐行对齐参考 qoder-machine.ts。参考用抓包 + 消融实验定位过:**缺这对头时 +-- 服务端不下发「可领取」的活动**(只回 1 条 VIEW_DETAILS 而非 +-- CLAIM_BENEFIT/CLAIMABLE/amount:100),症状正是用户报的 +-- 「没领过却显示今日已领取,去 IDE 看还能领」。要点: +-- 1. 两值**必须成对**出现,缺一即失效(消融表:全套 − 任一个 → 退回 1 条)。 +-- 2. 主路径是实时调 `runtime-info`:参数顺序**必须是 `[environment, '--account-stdin']`**, +-- environment 取 '3'(对应 asar 的 'global' 分支)。漏环境值会拿到另一套身份。 +-- stdin 传 `{"account":""}`,输出是含 `{` 的单行 JSON(取 machineToken/machineType)。 +-- 3. 退路是磁盘缓存 `machine_token.json`(token/type 字段)。 +-- 4. 两者都拿不到时**照常发请求**(不带这两个头)—— 保守降级, +-- 纯插件登录、未装桌面端的用户没有该文件,不能让整个积分功能报错。 +-- 5. 缓存解析结果:拿不到时也记 null,不反复读盘/反复 spawn。 +local QODER_MACHINE_ENVIRONMENT = "3" + +local function qoderMachineCache() + plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false } + return plugin.state.qoderMachine +end + +-- 读单个 machine_token.json(形状不符/读取失败返回 nil)。 +local function qoderParseMachineFile(path) + local f = io.open(path, "r") + if not f then return nil end + local raw = f:read("*a") + f:close() + local ok, rec = pcall(json.decode, raw or "") + if not ok or type(rec) ~= "table" then return nil end + local token, typ = rec.token, rec.type + if type(token) ~= "string" or token == "" then return nil end + if type(typ) ~= "string" or typ == "" then return nil end + return { token = token, type = typ } +end + +-- 候选路径(照参考 candidatePaths:env 覆盖 → APPDATA → macOS → Linux) +local function qoderMachineCandidatePaths() + local out = {} + local override = os.getenv and os.getenv("QODER_MACHINE_TOKEN_PATH") + if type(override) == "string" and override ~= "" then return { override } end + local appdata = os.getenv and os.getenv("APPDATA") + if type(appdata) == "string" and appdata ~= "" then + for _, d in ipairs({ "Qoder", "Qoder CN" }) do + out[#out + 1] = appdata .. "/" .. d .. "/SharedClientCache/cache/machine_token.json" + end + end + local home = os.getenv and (os.getenv("HOME") or os.getenv("USERPROFILE")) or nil + if type(home) == "string" and home ~= "" then + for _, d in ipairs({ "Qoder", "Qoder CN" }) do + out[#out + 1] = home .. "/Library/Application Support/" .. d + .. "/SharedClientCache/cache/machine_token.json" + out[#out + 1] = home .. "/.config/" .. d .. "/SharedClientCache/cache/machine_token.json" + end + end + return out +end + +-- 定位 runtime-info://.bin/umid-*/runtime-info(目录名带哈希,必须枚举) +local function qoderFindRuntimeInfo() + if not (os.getenv and os.getenv("HOME")) then return nil end + local exeName = "runtime-info" + for _, dirName in ipairs({ ".qoder", ".qoder-cn" }) do + local binDir = os.getenv("HOME") .. "/" .. dirName .. "/.bin" + local h = io.popen("ls -1 " .. binDir .. " 2>/dev/null") + if h then + local found + for line in h:lines() do + if line:sub(1, 5) == "umid-" then found = line break end + end + h:close() + if found then + local exe = binDir .. "/" .. found .. "/" .. exeName + local f = io.open(exe, "rb") + if f then f:close() return exe end + end + end + end + return nil +end + +-- 实时生成(照参考:参数顺序 [env, '--account-stdin'],stdin 传 {"account":""}) +local function qoderReadFromRuntimeInfo() + local exe = qoderFindRuntimeInfo() + if not exe then return nil end + -- 用临时文件喂 stdin:命令行传 JSON 的引号在 shell 下不可靠 + local tmp = "/tmp/llmsproxy-qoder-stdin-" .. tostring(math.random(1e12)) + local f = io.open(tmp, "w") + if not f then return nil end + f:write('{"account":""}') + f:close() + local cmd = ("timeout 20 %s %s %s < %s 2>/dev/null"):format( + exe, QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp) + local h = io.popen(cmd) + local out = h and h:read("*a") or "" + if h then h:close() end + os.remove(tmp) + if out == "" then return nil end + -- 输出形如(单行 JSON):取第一个 '{' 起 + local i = out:find("{", 1, true) + if not i then return nil end + local ok, rec = pcall(json.decode, out:sub(i)) + if not ok or type(rec) ~= "table" then return nil end + local token, typ = rec.machineToken, rec.machineType + if type(token) ~= "string" or token == "" then return nil end + if type(typ) ~= "string" or typ == "" then return nil end + return { token = token, type = typ } +end + +-- qoderMachineIdentity():返回 {token,type} 或 nil(拿不到时降级,不报错)。 +qoderMachineIdentity = function() + local c = qoderMachineCache() + if c.done then return c.identity end + local id = qoderReadFromRuntimeInfo() + if not id then + for _, path in ipairs(qoderMachineCandidatePaths()) do + id = qoderParseMachineFile(path) + if id then break end + end + end + c.done = true + c.identity = id + return id +end + +-- 可被 set_endpoints 覆盖(测试指到假上游用)。 +LOBSTERAI_ACTIVITY_BASE = "https://lobsterai-server.youdao.com" + +local function lobsteraiActivityHeaders(cred) + return { + ["Authorization"] = "Bearer " .. tostring(cred.access_token or ""), + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["User-Agent"] = "LobsterAI/0.1.0", + } +end + +-- lobsteraiClaim(plat, tok, args):完整 3 步领取。 +-- 与 codearts 同款:自己发多个请求,用 done 哨兵回给 claim_daily。 +local function lobsteraiClaim(plat, tok, args) + local cred = (args and args.credential) or nil + if type(cred) ~= "table" then return nil, "缺少凭据对象" end + local hdrs = lobsteraiActivityHeaders(cred) + -- clientVersion 必须是**动态真值**(与交换/续期/签到同一口径) + local cver = lobsteraiClientVersion(plat) + + -- ① 槽位 + local slotURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/slot?placement=desktop_sidebar" + .. "&clientVersion=" .. rfc3986Escape(cver) + .. "&containerApiVersion=2&platform=win32" + local sresp, serr = __llmsproxy_http({ url = slotURL, method = "GET", headers = hdrs }) + if serr or not sresp or sresp.status ~= 200 then + return { done = true, kind = "failed", + message = "活动槽位查询失败: " .. tostring(serr or (sresp and sresp.status)) } + end + local sok, srec = pcall(json.decode, sresp.body or "") + if not sok or type(srec) ~= "table" or srec.code ~= 0 or type(srec.data) ~= "table" then + return { done = true, kind = "failed", message = "活动槽位信封异常" } + end + local act = type(srec.data.activity) == "table" and srec.data.activity or {} + local slotState = tostring(srec.data.slotState or "") + local activityCode = tostring(act.activityCode or "") + local configRevision = tonumber(act.configRevision) or 0 + if slotState ~= "available" or activityCode == "" then + -- 正常业务状态,不是失败 + return { done = true, kind = "inactive", message = "无可用活动(slotState=" .. slotState .. ")" } + end + + -- ② 上下文 + local ctxURL = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/" + .. rfc3986Escape(activityCode) .. "/context?configRevision=" .. tostring(configRevision) + local cresp, cerr = __llmsproxy_http({ url = ctxURL, method = "GET", headers = hdrs }) + if cerr or not cresp or cresp.status ~= 200 then + return { done = true, kind = "failed", + message = "活动上下文查询失败: " .. tostring(cerr or (cresp and cresp.status)) } + end + local cok, crec = pcall(json.decode, cresp.body or "") + if not cok or type(crec) ~= "table" or crec.code ~= 0 or type(crec.data) ~= "table" then + return { done = true, kind = "failed", message = "活动上下文信封异常" } + end + local state = type(crec.data.state) == "table" and crec.data.state or {} + if state.claimedToday == true then + return { done = true, kind = "already-claimed", message = "今天已签到" } + end + local hasCheckIn = false + for _, a in ipairs(crec.data.actions or {}) do + if a == "check_in" then hasCheckIn = true break end + end + if not hasCheckIn then + return { done = true, kind = "inactive", message = "当前不可签到" } + end + + -- ③ 领取(客户端幂等键) + local body = json.encode({ configRevision = configRevision, + idempotencyKey = newToken():sub(1, 8) .. "-" .. newToken():sub(9, 12) .. "-4" + .. newToken():sub(14, 16) .. "-8" .. newToken():sub(18, 20) .. "-" .. newToken():sub(21, 32), + payload = {} }) + local url = LOBSTERAI_ACTIVITY_BASE .. "/api/client-activities/" + .. rfc3986Escape(activityCode) .. "/actions/check_in" + local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = hdrs, body = body }) + if err or not resp or resp.status ~= 200 then + return { done = true, kind = "failed", + message = "领取请求失败: " .. tostring(err or (resp and resp.status)) } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" or rec.code ~= 0 then + local why = (ok and type(rec) == "table") and tostring(rec.msg or rec.message or rec.code) or "信封异常" + return { done = true, kind = "failed", message = "领取失败: " .. why } + end + -- 积分字段三级回退(参考:creditsGranted → rewardCredits → credits) + local result = type(rec.data) == "table" and type(rec.data.result) == "table" and rec.data.result or {} + local credit = tonumber(result.creditsGranted) or tonumber(result.rewardCredits) or tonumber(result.credits) + local msg = "已签到" + if credit then msg = msg .. ",获得 " .. tostring(credit) .. " 积分" end + return { done = true, kind = "success", message = msg } +end + +-- 绑定 codearts 的领取钩子。它自己跑完 4 步流程(账户→活动→claim→confirm), +-- 用 done 哨兵返回,claim_daily 识别后不再发送单个请求。 +do + for _, p in ipairs(PLATFORMS) do + if p.id == "codearts" then + p.claim_fn = function(plat, tok, args) return codeartsClaim(plat, tok, args) end + break + end + end + for _, p in ipairs(PLATFORMS) do + if p.id == "lobsterai" then + p.claim_fn = function(plat, tok, args) return lobsteraiClaim(plat, tok, args) end + break + end + end + for _, p in ipairs(PLATFORMS) do + if p.id == "qoder" then + p.claim_fn = function(plat, tok, args) return qoderClaim(plat, tok, args) end + break + end + end +end + + -- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。 -- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。 local function declaredHosts() @@ -722,6 +1978,12 @@ local function declaredHosts() if p.qr then addHost(p.qr.api_base) end if p.device then addHost(p.device.code_url); addHost(p.device.token_url) end if p.state then addHost(p.state.state_url); addHost(p.state.token_url); addHost(p.state.account_url) end + if p.models_remote then + for _, m in ipairs(p.models_remote.endpoints or {}) do addHost(m.url) end + end + -- 能力元数据来源(照参考 opencode-capability)。必须声明,否则出站被拒 + -- 而 refreshCapabilities 的 pcall 会把错误吞掉 —— 表现是"能力永远是空"。 + if p.capability_key then addHost("https://models.dev/api.json") end if p.balance then addHost(p.balance.url) end if p.checkin_status then addHost(p.checkin_status.url) end if p.callback then addHost(p.callback.exchange_url) end @@ -750,32 +2012,23 @@ end -- 行为逐字对齐参考实现,测试用例也照它搬(见 channels_device_test.go): -- -- 1. PKCE S256:verifier = 随机字节 base64url;challenge = SHA256(verifier) --- 的 base64url(参考 createMinimaxPkce)。 +-- 的 base64url(参考 createMinimaxPkce)。 -- 2. 轮询决策分支的**顺序**就是行为(参考 pollMinimaxDeviceToken): --- HTTP 200 + status=pending → 继续等 --- HTTP 200 + status=slow_down → interval += 5s,继续等 --- HTTP 200 + status=denied / --- access_denied → 会话 failed(用户拒绝) --- HTTP 200 + status=expired / --- expired_token → 会话 failed(码过期) --- 非 200 + error=authorization_pending → 继续等(OAuth 标准形态) --- 非 200 + error=slow_down → interval += 5s,继续等 --- HTTP 200(无上述状态) → 成功,提取 access_token --- 其它 → 会话 failed(带 error 文本) --- ⚠️ 「两种还在等」都要认:形一是 200+status 字段,形二是非 200+error 字段。 +-- HTTP 200 + status=pending → 继续等 +-- HTTP 200 + status=slow_down → interval += 5s,继续等 +-- HTTP 200 + status=denied / +-- access_denied → 会话 failed(用户拒绝) +-- HTTP 200 + status=expired / +-- expired_token → 会话 failed(码过期) +-- 非 200 + error=authorization_pending → 继续等(OAuth 标准形态) +-- 非 200 + error=slow_down → interval += 5s,继续等 +-- HTTP 200(无上述状态) → 成功,提取 access_token +-- 其它 → 会话 failed(带 error 文本) +-- 注意「两种还在等」都要认:形一是 200+status 字段,形二是非 200+error 字段。 -- 3. 轮询由页面驱动(poll_login 每次调用推进一步),不在 Lua 里阻塞循环: --- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。 +-- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。 -- b64url:内核的 base64_encode 是标准 base64;PKCE 要 base64url(无 +/= )。 -local function b64url(s) - return (base64_encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=+$", "")) -end - --- hexToBin:把 hex 字符串还原成二进制字符串(PKCE challenge 输入用)。 -local function hexToBin(h) - return (h:gsub("..", function(cc) return string.char(tonumber(cc, 16)) end)) -end - -- ---- 讯飞账号(CAccount)HMAC-SHA1 签名(Loomy 用)---- -- 逐段复刻参考 loomy-sign.ts。任何偏差都会让账号端点返回鉴权失败。 -- @@ -787,7 +2040,7 @@ end -- 认证头前缀是 `account {ak}:{sig}`(**不是** Bearer)。 -- rfc3986Escape:encodeURIComponent 等价 + 补转 ! ' ( ) * -local function rfc3986Escape(v) +rfc3986Escape = function(v) v = tostring(v or "") v = v:gsub("([^A-Za-z0-9%-%._~])", function(c) return string.format("%%%02X", string.byte(c)) @@ -882,9 +2135,9 @@ local function devicePkce() end -- deviceOneStep 推进一次设备码轮询,返回 (done, payload)。 --- done=true payload={ ok=true, access_token=.., refresh_token=.., account=.. } --- 或 { ok=false, reason="denied"|"expired", message=.. } --- done=false payload=nil(还在等:pending / slow_down) +-- done=true payload={ ok=true, access_token=.., refresh_token=.., account=.. } +-- 或 { ok=false, reason="denied"|"expired", message=.. } +-- done=false payload=nil(还在等:pending / slow_down) -- sess 携带 device 上下文(device_code/verifier/interval),被本函数原地更新 --(slow_down 后 interval 增大,与参考一致)。 local function deviceOneStep(plat, sess) @@ -917,10 +2170,10 @@ local function deviceOneStep(plat, sess) if not ok or type(rec) ~= "table" then rec = {} end -- 判据形态分两套(这正是 MiniMax 与 Cline 的核心差异,参考注释反复强调): - -- minimax(默认):看 HTTP 200 + status 字段;非 200 才看 error 字段。 - -- workos(Cline):看响应体 error 字段(HTTP 可能非 2xx); - -- slow_down 退避 d.slow_down_step 秒(WorkOS 会持续限流不退避的客户端); - -- access_denied / expired_token / invalid_grant 同组判失败。 + -- minimax(默认):看 HTTP 200 + status 字段;非 200 才看 error 字段。 + -- workos(Cline):看响应体 error 字段(HTTP 可能非 2xx); + -- slow_down 退避 d.slow_down_step 秒(WorkOS 会持续限流不退避的客户端); + -- access_denied / expired_token / invalid_grant 同组判失败。 -- 退避步长不硬编码:slow_down_step 缺省 5(MiniMax 语义)。 local workos = (d.judge == "workos") local status = rec.status @@ -928,8 +2181,7 @@ local function deviceOneStep(plat, sess) local step = tonumber(d.slow_down_step) or 5 -- 「404/pending = 继续轮询」形态(Qoder)。 - -- - -- 实测依据(参考注释):qoder.com 的 poll 路径返回 401,而 + -- -- 实测依据(参考注释):qoder.com 的 poll 路径返回 401,而 -- openapi.qoder.sh 对「无待授权会话」返回 **404**,语义是"还没有会话,继续等" -- 而非失败。把 404 当失败会让登录永远失败。 -- 判定由平台声明 judge="pending404" 驱动,不在这里硬编码平台名。 @@ -1143,8 +2395,17 @@ local function storeDir() -- 显式覆盖优先(测试与多实例部署都靠它隔离,避免读到别处的生产配置) local env = os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR") if type(env) == "string" and env ~= "" then return env end + -- 内核交给本插件的目录(ground truth,不需要猜)。 + local kern = __llmsproxy_plugin_dir + if type(kern) == "string" and kern ~= "" then return kern end local s = plugin.state.settings and plugin.state.settings.store_dir if type(s) == "string" and s ~= "" then return s end + -- ⚠️ 优先用内核交给我们的**本插件目录**(`__llmsproxy_plugin_dir`): + -- 那是 ground truth,不需要猜。此前这里直接落到"读生产 config.yaml"的兜底, + -- 在**未设 LLMSPROXY_PLUGIN_DIR 的测试里**会命中生产路径 —— 测试读生产存储、 + -- 也写生产存储。一次全量 internal/lua 就把生产账号池覆盖成测试的空 pool + -- (实测:13 个账号 → 0,凭据还在保险库,症状是"账号凭空消失")。 + -- 判据:internal/lua/store_dir_isolation_test.go。 for _, path in ipairs({ "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml" }) do local f = io.open(path, "r") if f then @@ -1164,6 +2425,20 @@ end local PERSIST_KEYS = { "accounts", "pool", "modelDisabled", "settings", "providerOrder", "log", "injected_platforms", + -- 远端拉到的模型目录缓存(远端为主、兜底补位的结果)。 + -- 缓存它而非每次请求都拉:上游目录是分钟级稳定,UI 却是秒级刷新。 + "remoteModels", + -- 已失效模型记录(阳性 404 证据)。必须持久化:不落盘的话重启就全忘了, + -- 用户又要重新被 404 打一遍(照参考 DEAD_MODELS_FILE 的落盘语义)。 + "deadModels", + -- 通用 TTL 缓存(余额原料等)。持久化可让重启后不立刻重打上游。 + "ttlCache", + -- 模型能力元数据(models.dev 的能力/免费/上下文)。持久化是**必须**的: + -- 冷启动时要能直接读缓存,否则首个模型列表要么空白、要么等 5MB 下载。 + "capabilities", "capabilitiesMeta", + -- 目录闸门状态(冷却/in-flight)。也落盘:重启后冷却窗口应继续有效, + -- 否则"重启即重试坏上游"(照参考把闸门状态随插件存活的语义)。 + "catalogGate", } local function persistState() @@ -1176,6 +2451,11 @@ local function persistState() local body = json.encode({ version = 1, state = snap }) if not body then return false, "json.encode 失败" end local tmp = path .. ".tmp" + -- 目录可能还不存在(首次运行、或内核交给我们的目录尚未创建)。 + -- 不 mkdir 就直接 io.open 会失败,而失败只打到 stderr —— 表现为 + -- "状态永不落盘"(重启即丢),日志里一行「状态落盘失败」很容易被忽略。 + local dir = path:match("^(.*)/[^/]+$") + if dir and dir ~= "" then os.execute("mkdir -p '" .. dir .. "' 2>/dev/null") end local w = io.open(tmp, "w") if not w then return false, "无法写入 " .. tmp end w:write(body) @@ -1361,9 +2641,9 @@ end -- -- 本助手统一取出可用的 access token(及 refresh_token),使所有 HTTP 消费点 -- (Bearer 头、HMAC 签名)无需关心存的是哪种形态: --- 裸 token → 原样返回 --- 完整凭据 JSON 字符串 → 取 .access_token --- 已经是 table → 取 .access_token +-- 裸 token → 原样返回 +-- 完整凭据 JSON 字符串 → 取 .access_token +-- 已经是 table → 取 .access_token -- 返回 (accessToken, refreshToken, fullTableOrNil)。两者都空时返回 (nil, nil, nil)。 local function credentialOf(ref) local raw = ref and __llmsproxy_secrets.get(ref) @@ -1453,9 +2733,9 @@ end -- absExpiryMs 把绝对字段或相对秒数换算为毫秒时间戳字符串。 -- 对齐参考 absoluteExpiryMs: --- ① 绝对字段(expiresAt/refreshExpiresAt)存在 → 归一化为毫秒字符串 --- ② 否则用相对秒数(expiresIn/refreshExpiresIn)+ access_token 的 JWT iat 兜底 --- ③ 都没有 → 返回空串(由 credentialExpiresAtMs 再从 JWT exp 兜底) +-- ① 绝对字段(expiresAt/refreshExpiresAt)存在 → 归一化为毫秒字符串 +-- ② 否则用相对秒数(expiresIn/refreshExpiresIn)+ access_token 的 JWT iat 兜底 +-- ③ 都没有 → 返回空串(由 credentialExpiresAtMs 再从 JWT exp 兜底) -- 实测:CodeBuddy 的 /v2/plugin/auth/token 只返回 expiresIn,没有 expiresAt; -- 不换算的话凭据的 expires_at 永远是空串,UI 一直显示"有效期未知"。 local function absExpiryMs(rec, absKey, relKey, accessToken) @@ -1482,8 +2762,8 @@ end -- buildCredential 把 token 响应 + account 响应组合为可持久化的完整凭据 JSON。 -- 逐字段对齐参考 buddy.ts 的 buildCredential / parseTokenData / parseAccountData: --- access_token / refresh_token / expires_at / refresh_expires_at / --- token_type / scope / domain / user_id / nickname / enterprise_id / account_type +-- access_token / refresh_token / expires_at / refresh_expires_at / +-- token_type / scope / domain / user_id / nickname / enterprise_id / account_type -- 昵称回填顺序:account.nickname → JWT.nickname → JWT.preferred_username。 -- expires_at 同理:token.expiresAt 为空时由 absExpiryMs 用 expiresIn 或 JWT exp 兜底。 local function buildCredential(data, accountData) @@ -1545,11 +2825,289 @@ local function credentialExpiresAtMs(cred) end -- credentialIsRefreshable 凭据是否携带可静默续期的 refresh_token。 +-- ⚠️ 必须定义在 refreshCredential **之前**:Lua 的 local 只从定义处向后可见, +-- 在定义前调用会拿到 nil 并报 "attempt to call a nil value"(第 10 次踩)。 local function credentialIsRefreshable(cred) return type(cred) == "table" and type(cred.refresh_token) == "string" and cred.refresh_token ~= "" end +-- urlencode:表单编码(Google 续期走 x-www-form-urlencoded)。 +-- ⚠️ 不能只转义常见几个字符:client_secret / refresh_token 里会出现 +-- `-` `_` `.` `~` 之外的各种符号,漏转义会让服务端收到被截断的值。 +local function urlencode(v) + return (tostring(v or ""):gsub("[^%w%-%._~]", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +-- ============================================================================ +-- 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts) +-- ============================================================================ +-- ⚠️ **此前这里是假的**:`account_refresh` 只重同步了源、一个换 token 的请求 +-- 都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」而实际 +-- 什么都没续。这是**比不做更坏**的形态:日志在说谎,排查时会去看网络、 +-- 看上游,而真因是根本没有这段代码。 +-- +-- 参考的实现要点(每一条都是踩过的坑): +-- +-- 1. **续期请求体各平台字段名不同**,写错服务端不给明确报错,只回一个泛化 +-- 的认证失败,极难定位: +-- * Cline:驼峰 `refreshToken` + `grantType`(**不是** OAuth 标准的 +-- `refresh_token`/`grant_type`); +-- * Gemini/Google:标准 `refresh_token` + `grant_type=refresh_token` +-- (表单编码,走 client_id/client_secret); +-- * 其余走各平台自己的 exchange。 +-- 2. **响应字段名同样不统一**:Cline 是 `accessToken`(驼峰)+ `{success,data}` +-- 信封;Google 是 `access_token`。解析必须容忍两种。 +-- 3. **Google 偶尔轮换 refresh_token**:响应里给了新的**必须回写**,否则下次 +-- 续期用旧的直接失效。 +-- 4. **`expires_in` 是相对秒数**,必须换算成绝对时刻再写回索引 —— +-- 否则 `refresh_creds` 每轮都判定"快过期了"又续一遍。 +-- +-- 声明式设计(插件域内,内核不参与):平台用 `refresh = { url=, style=, +-- body=, ... }` 描述自己的续期,本助手按 style 组装。 +local REFRESH_STYLES = { + -- Cline:驼峰字段 + JSON body + {success,data} 信封 + cline = { + body = function(cred, r) + return json.encode({ refreshToken = cred.refresh_token or "", + grantType = "refresh_token" }) + end, + headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }, + -- 响应套 {success,data} 且字段是驼峰 accessToken(照 parseClineTokenPayload) + pick = function(rec) + local d = type(rec.data) == "table" and rec.data or rec + return d.accessToken or d.access_token, d.refreshToken or d.refresh_token + end, + }, + -- Google/Gemini:标准 OAuth 表单编码 + google = { + form = true, + body = function(cred, r) + return "client_id=" .. urlencode(r.client_id or "") + .. "&client_secret=" .. urlencode(r.client_secret or "") + .. "&refresh_token=" .. urlencode(cred.refresh_token or "") + .. "&grant_type=refresh_token" + end, + headers = { ["Content-Type"] = "application/x-www-form-urlencoded", + ["Accept"] = "application/json" }, + -- ⚠️ Google 偶尔轮换 refresh_token:给了新的必须回写 + pick = function(rec) return rec.access_token, rec.refresh_token end, + }, +} + +-- refreshCredential(plat, acct):真发一次续期请求,成功则**回写凭据与索引**。 +-- 返回 (ok, message)。失败原因必须可诊断(不能只说"失败了")。 +local function refreshCredential(plat, acct) + local r = plat.refresh + if type(r) ~= "table" or not r.url then return false, "该平台未声明续期方式" end + local cred = credentialFull(acct.credentialRef) + if type(cred) ~= "table" then return false, "凭据无法解析" end + if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end + local style = REFRESH_STYLES[r.style or "google"] + if not style then return false, "未知续期风格: " .. tostring(r.style) end + local body = style.body(cred, r) + local resp, err = __llmsproxy_http({ + url = r.url, method = r.method or "POST", headers = style.headers, body = body, + }) + if err then return false, "续期请求失败: " .. tostring(err) end + if not resp or resp.status ~= 200 then + return false, "续期 HTTP " .. tostring(resp and resp.status) + end + local okd, rec = pcall(json.decode, resp.body or "") + if not okd or type(rec) ~= "table" then return false, "续期响应无法解析" end + local newTok, newRefresh = style.pick(rec) + if type(newTok) ~= "string" or newTok == "" then + -- 服务端的失败信息要带出来(照参考:不吞诊断信息) + local msg = rec.error_description or rec.error or rec.message + return false, "续期响应无 access_token" .. (msg and (": " .. tostring(msg)) or "") + end + -- 回写凭据(**只能走这条路写凭据** —— 手工塞密文会导致二次加密) + cred.access_token = newTok + if type(newRefresh) == "string" and newRefresh ~= "" then + cred.refresh_token = newRefresh -- Google 会轮换 + end + -- expires_in 是**相对秒数** → 换算绝对时刻(否则每轮都判定"快过期") + local rec2 = type(rec.data) == "table" and rec.data or rec + local ei = tonumber(rec2.expires_in or rec.expires_in) + if ei and ei > 0 then + cred.expires_at = tostring((os.time and os.time() or 0) + ei) + end + __llmsproxy_secrets.set(acct.credentialRef, json.encode(cred)) + -- 回写索引的过期时刻(照 expiry-sync:按需续期**必须**回写,否则会出现 + -- 「显示已过期、但发消息正常」的数据源分叉) + if cred.expires_at then + local ms = credentialExpiresAtMs(cred) + if ms then acct.expiresAt = math.floor(ms / 1000) end + end + acct.refreshable = true + poolAdd(plat.id, acct) + markDirty() + return true, "已续期" +end + -- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true } +-- ============================================================================ +-- 远端模型目录:**远端为主 + 兜底补位**(照参考的 reconcileWithFallback) +-- ============================================================================ +-- +-- 为什么不只用硬编码名单:上游模型目录是**会变**的(新增/下线/改名), +-- 硬编码必然过期。为什么不只用远端:远端可能拉不到(未登录/网络抖动/端点变更), +-- 那时平台就"没有模型可用"了。 +-- +-- 故正确形态是**合并**而非"远端失败就用兜底"的简单回退(参考 cline-models.ts +-- 明确写了这一条):远端有的以远端为准,远端没有的用兜底表补。 +-- +-- 注意:兜底表是**编译期快照**,价格/倍率会变,故兜底条目不携带价格类字段。 + +-- 取该平台**当前有效**的模型目录(远端优先,兜底补位,剔除黑名单)。 +-- 前向声明:deadModelIds 定义在下方(失效模型存储一节),而 effectiveModels +-- 在它之前就要剔除失效模型。Lua 的 local 只从定义处向后可见 —— 第 9 次踩。 +local deadModelIds + +local function effectiveModels(provider) + local plat = platformById(provider) + if not plat then return {} end + local remote = plugin.state.remoteModels and plugin.state.remoteModels[provider] + local out, seen = {}, {} + -- 远端在前(权威顺序) + if type(remote) == "table" and type(remote.ids) == "table" then + for _, id in ipairs(remote.ids) do + if type(id) == "string" and id ~= "" and not seen[id] then + seen[id] = true out[#out+1] = id + end + end + end + -- 兜底补位(只补远端没有的) + for _, id in ipairs(plat.models or {}) do + if not seen[id] then seen[id] = true out[#out+1] = id end + end + -- 剔除**有阳性证据**已失效的模型(照参考 dead-model-store 的剔除语义)。 + -- ⚠️ 只剔这里记录过的(真正 404 过的),**绝不**拿远端目录反推下架 —— + -- buddy/qoder 那类平台的远端目录残缺,反推会删掉可用模型。 + local dead = deadModelIds(provider) + local kept = {} + for _, id in ipairs(out) do + if not dead[id] then kept[#kept+1] = id end + end + return kept +end + +-- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。 +-- 每个分支的字段路径都照参考实现,含注释点名的坑。 +local function parseRemoteModels(kind, body) + local ok, rec = pcall(json.decode, body or "") + if not ok or type(rec) ~= "table" then return {} end + local out = {} + + if kind == "id_list" then + -- OpenAI 风格 {data:[{id}]};也有平台直接给数组或 {models:[...]} + local arr = rec.data or rec.models or rec + if type(arr) == "table" then + for _, m in ipairs(arr) do + local id = type(m) == "table" and m.id or (type(m) == "string" and m) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + + elseif kind == "buddy_config" then + -- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。 + -- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。 + local d = rec.data or rec + local arr = d.models or d.agents or rec.models + if type(arr) == "table" then + for _, m in ipairs(arr) do + local id = type(m) == "table" and (m.id or m.modelId or m.name) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + + elseif kind == "zcode_configs" then + -- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象** + -- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。 + -- Lua 里表无此区别,但要同时支持数组与 map 两形态。 + local raw = rec.data and rec.data.builtinModels + if type(raw) == "table" then + for _, m in pairs(raw) do + local id = type(m) == "table" and (m.modelId or m.id) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + + elseif kind == "cline_merge" then + -- 照参考 cline-models.ts:**必须同时打两个端点**。 + -- recommended-models 是**唯一权威的 free 集合**;/api/v1/models 的 460 个 + -- id 里根本没有 cline-free/*。故这里把它们合并去重。 + local function pushFrom(node) + if type(node) ~= "table" then return end + local arr = node.data or node + if type(arr) ~= "table" then return end + for _, m in ipairs(arr) do + local id = type(m) == "table" and m.id or (type(m) == "string" and m) + if type(id) == "string" and id ~= "" then out[#out+1] = id end + end + end + -- free 数组优先在前(它含 cline-free/* 与 stealth/*) + pushFrom(rec.free) + pushFrom(rec) + end + + return out +end + +-- refreshRemoteModels(provider):拉取并缓存该平台的远端目录。 +-- 返回 (ids, err);**任何失败都不清空既有缓存**(宁可留旧目录也别让平台变空)。 +local function refreshRemoteModels(provider) + local plat = platformById(provider) + if not plat then return nil, "未知平台" end + local mr = plat.models_remote + if not mr then return nil, "该平台无远端目录端点" end + -- 取一个可用账号的凭据(无账号则无凭据,仅当 auth=none 时仍可拉) + local tok + for _, a in ipairs(poolList(provider)) do + if a.enabled ~= false then + local t = credentialOf(a.credentialRef) + if t and t ~= "" then tok = t break end + end + end + if (not tok or tok == "") and mr.auth ~= "none" then return nil, "无可用凭据" end + + local merged, seen = {}, {} + local lastErr + for _, ep in ipairs(mr.endpoints or {}) do + local url = ep.url + if ep.query then url = url .. "?" .. ep.query end + local headers = { ["Accept"] = "application/json" } + local useAuth = ep.url_auth ~= false and mr.auth ~= "none" + if useAuth and tok and tok ~= "" then + headers["Authorization"] = "Bearer " .. tok + end + -- 平台级身份头(buddy 必须带 X-Product-Code 等,否则会被当成另一个产品) + if mr.headers_fn and plat.headers_fn then + local extra = plat.headers_fn(tok or "", nil) + for k, v in pairs(extra or {}) do headers[k] = v end + end + local resp, err = __llmsproxy_http({ + url = url, method = ep.method or "GET", headers = headers, + }) + if err or not resp or resp.status ~= 200 then + lastErr = (err or ("HTTP " .. tostring(resp and resp.status))) + else + for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body)) do + if not seen[id] then seen[id] = true merged[#merged+1] = id end + end + end + end + if #merged == 0 then return nil, lastErr or "远端返回空目录" end + if not plugin.state.remoteModels then plugin.state.remoteModels = {} end + plugin.state.remoteModels[provider] = { + ids = merged, + fetched_at = os.time(), + } + markDirty() + return merged, nil +end + local function disabledModelsOf(provider) if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end local m = plugin.state.modelDisabled[provider] @@ -1569,15 +3127,26 @@ end -- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。 local CHANNEL_CREDITS = { codearts = { claim = true }, - buddy = { claim = true, status = true }, + -- ⚠️ buddy **没有**每日签到。此前这里写 claim=true 是错的:claim_daily 的 + -- 前置条件(plat.checkin 或 claim_fn)都不满足,于是每次调用都返回 + -- "CodeBuddy(腾讯) 不支持每日签到(平台未声明 checkin)" —— 一个自相矛盾的 + -- 文案(表说支持、函数说不支持)。对齐参考:buddy 只有**余额**,无 claim。 + buddy = { status = true }, lobsterai = { claim = true }, qoder = { claim = true }, trae = { claim = true }, cline = { unit = "dollars" }, loomy = { claim = true }, - raccoon = {}, + -- raccoon 的领取 = **桌面端登录奖励**(每号一次),参考 + -- raccoon-credits.ts:claimRaccoonLoginReward。此前我们声明为空表 → 白丢一个 + -- 可领渠道。幂等靠响应的 `granted` 字段(重复领取同样 HTTP 200 但 granted=false)。 + raccoon = { claim = true, unit = "points" }, minimax = { claim = true, status = true }, - zcode = { claim = true, unit = "tokens" }, + -- zcode 有签到,但每次领取都要现场产**阿里云 captcha**(headful Chromium, + -- 且阿里云按设备限流 150 次/小时)。故照参考 isAutoCheckinExcluded: + -- **自动**签到跳过它(代价发生在调用期间,等它报错来不及); + -- 用户仍可**手动**点领取 —— 浏览器弹出来是用户能理解的交互。 + zcode = { claim = true, unit = "tokens", auto_exclude = true }, gemini = { unit = "quota", quota = true }, } @@ -1595,31 +3164,70 @@ local function syncAccountSource(provider, accountId) local plat = platformById(provider) local acct = poolGet(provider, accountId) if not plat or not acct then return end - -- 凭据可能是裸 token 或完整凭据 JSON;上游源只吃 access token。 - local tok = credentialOf(acct.credentialRef) - if not tok or tok == "" then return end + -- 凭据有两种消费方式,取决于平台的适配器: + -- * 多数平台:适配器发 `Authorization: Bearer ` + -- → 这里必须交出**裸 access token**(不能把整个 JSON 当 key 发上去) + -- * 签名平台(codearts):适配器在 build_headers 里**自己从完整凭据 JSON + -- 解 AK/SK 并签名** → 这里必须交出完整凭据 JSON,交出裸 token 反而 + -- 让适配器签不出来(真实缺陷:codearts 账号加了却不出源,因为 + -- credentialOf 找不到 access_token 而静默 return) + -- 用 plat.credential_shape 声明,默认 "token"(保持既有平台行为不变)。 + local tok, err + if plat.credential_shape == "full" then + tok = __llmsproxy_secrets.get(acct.credentialRef) + err = (tok == nil or tok == "") and "无凭据" or nil + else + tok = credentialOf(acct.credentialRef) + err = (tok == nil or tok == "") and "无凭据" or nil + end + if err then return end local name = accountSourceName(provider, accountId) local models = {} local disabled = disabledModelsOf(provider) - for _, m in ipairs(plat.models or {}) do + -- 用**有效目录**(远端优先、兜底补位),不是硬编码表 + for _, m in ipairs(effectiveModels(provider)) do if not disabled[m] then models[#models+1] = { id = m, kind = "chat", priority = 20 } end end -- 重建:先 remove(容错:源可能不存在)再 add __llmsproxy_kernel.call("remove_source", { name = name }) - local r, aerr = __llmsproxy_kernel.call("add_source", { - source = { - name = name, - base_url = plat.base_url, - adapter = plat.adapter or "openai", - api_key = tok, - max_concurrent = 2, - models = models, - }, - }) - if aerr then logEvent("接入源失败 " .. name .. ": " .. aerr) - else logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)") end + local src = { + name = name, + base_url = plat.base_url, + adapter = plat.adapter or "openai", + api_key = tok, + -- ⚠️ max_concurrent=1 + rpm 节流,而不是 2。 + -- + -- 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量, + -- 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下: + -- GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过 + -- GLM-5.3 74 次 200 21 次重试 6 次最终 429 + -- 两条措施缺一不可: + -- ① **串行**:同一时刻只允许一个上游请求在飞(并发发起必然浪费一个配额) + -- ② **最小间隔**:串行只保证"不重叠",不保证"有间隔" —— 连续放行的两次 + -- 调用可能只隔几十毫秒,仍会撞并发窗口。故用 rpm 让内核把它们隔开。 + -- 这两条都是**客户端自保**(防患于未然),与"上游明确要求排队"是两回事。 + max_concurrent = 1, + rpm = plat.rpm or 20, -- 默认 3 秒一个请求,可按平台覆盖 + models = models, + } + -- 平台的 chat 端点常与 base_url 不是简单拼接(Gemini 走私有 + -- /v1internal: 动词、zcode 只有一条 plan 路径)。声明式覆盖,内核只当 + -- 不透明字符串用 —— 不需要内核知道任何厂商知识。 + if plat.endpoint then src.endpoint = plat.endpoint end + if plat.endpoint_stream then src.endpoint_stream = plat.endpoint_stream end + if plat.headers_extra then src.headers = plat.headers_extra end + local r, aerr = __llmsproxy_kernel.call("add_source", { source = src }) + -- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会 + -- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。 + if aerr then + logEvent("接入源失败 " .. name .. ": " .. tostring(aerr)) + elseif r and r.json and r.json.ok == false then + logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error)) + else + logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)") + end end local function dropAccountSource(provider, accountId) @@ -1644,7 +3252,7 @@ end -- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。 local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理 -local function newToken() +newToken = function() -- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex) return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels")) end @@ -1659,6 +3267,18 @@ local function sweepSessions() local now = os.time and os.time() or 0 for id, s in pairs(sessions()) do if s.expires_at and s.expires_at < now then + -- 会话过期时要顺带回收它为回调起的 loopback 接收器 —— 否则用户放弃 + -- 登录后那些 python 监听会一直挂着(进程泄漏,且白占端口)。 + reapLoopback(id) + -- 同一道理:callback 流会在服务端起一整套浏览器栈(Xvfb/chromium/VNC)。 + -- 会话过期就是「用户已经放弃」,必须把栈收掉 —— 否则每次放弃登录都留一套 + -- 进程常驻(实测:重复跑测试/重复发起登录会攒出 7 套 websockify+x11vnc)。 + if s.owns_browser then + local cur = browserStack() + if cur.owner == id then + reapBrowser() + end + end sessions()[id] = nil end end @@ -1702,15 +3322,154 @@ end -- reapBrowser 回收本插件拉起的整组进程。进程是插件起的,插件负责杀 —— -- 留一个孤儿 chromium 会一直占着显示与内存,且下次 spawn 会撞端口。 +-- killProcsMatching(needle):杀掉 cmdline 里含 needle 的进程(含自己不算)。 +-- +-- 为什么不用记录的 PID:`setsid` 在「已是进程组长」时会 fork,shell 的作业控制 +-- 也会另开进程组 —— 于是 `$!` 未必是真正的服务进程。实测后果:kill 记录的 PID +-- 杀不掉 chromium 的 zygote/renderer,它们继续活着并重建 profile 目录, +-- 一次 core 全包跑出 7 套残留栈(websockify×7 + x11vnc×7 + 7 个 profile 目录)。 +-- +-- 为什么不用 pkill -f:那个模式会出现在**我们自己这条 shell 命令行**里 → +-- pkill 把执行它的 shell 一起杀掉(真实踩过两次,命令链直接中断)。 +-- 这里直接读 /proc(不经 shell 传模式),因此不会自匹配。 +local function killProcsMatching(needle) + if not needle or needle == "" then return 0 end + local h = io.popen("ls /proc 2>/dev/null") + if not h then return 0 end + local victims = {} + for entry in h:lines() do + if entry:match("^%d+$") then + local f = io.open("/proc/" .. entry .. "/cmdline", "r") + if f then + local raw = f:read("*a") or "" + f:close() + -- cmdline 以 NUL 分隔;换成空格以便匹配 + local cmd = raw:gsub("%z", " ") + if cmd ~= "" and cmd:find(needle, 1, true) then + victims[#victims + 1] = entry + end + end + end + end + h:close() + -- 同样先 TERM 再 KILL:chromium 也持有 X 的 SHM 段,强杀会留孤儿段 + -- (见 reapBrowser 关于 shmmni 耗尽的说明)。 + for _, pid in ipairs(victims) do + os.execute("kill -TERM " .. pid .. " 2>/dev/null") + end + if #victims > 0 then + os.execute("sleep 0.3") + end + for _, pid in ipairs(victims) do + os.execute("kill -9 " .. pid .. " 2>/dev/null") + end + return #victims +end + local function reapBrowser() local b = browserStack() + local sess = b.session + local disp = b.display or (sess and sess.display) + local prof = b.profile + -- 按**唯一标识**回收(显示器号 / profile 目录),不依赖记录的 PID —— + -- 理由见 killProcsMatching 注释($! 未必是真正的服务进程,只信它会留下 + -- zygote/renderer 与 x11vnc/websockify 残留)。这些标识都是本次会话独有的, + -- 因此不会误杀别的会话。 + -- 按 **profile 目录**匹配杀 chromium 及其子进程:tag 是「时间+随机」故全局唯一, + -- 不会误杀别的会话。**不能**按显示器号匹配 —— 显示器号会在空出后被复用, + -- 于是 A 会话的收尾会把 B 会话正在用的 `Xvfb :91` 一起杀掉 + -- (实测后果:chromium 报 "X connection error",栈起不来)。 + if prof then + killProcsMatching("user-data-dir=" .. prof) + end + -- Xvfb / x11vnc / websockify 命令行里没有唯一标识,只信记录的 PID。 + -- + -- **先 SIGTERM 再 SIGKILL**,不能直接 -9:X 服务的 MIT-SHM 共享内存段 + -- 被强杀时无人释放,会变成系统级孤儿段(nattch=0 但占着 shmmni 名额)。 + -- 实测:单个 Xvfb 约占 84 段,内核 kernel.shmmni=4096 → 约 48 次异常退出 + -- 就把它用满,此后**宿主上任何**新进程 shmget 都返回 + -- “No space left on device”(x11vnc 就从 0.2s 能起变成永远起不来)。 + -- 优雅终止让 X 自己清理这些段。 + for _, pid in ipairs(b.pids or {}) do + os.execute("kill -TERM " .. tostring(pid) .. " 2>/dev/null") + end + if #(b.pids or {}) > 0 then + os.execute("sleep 0.4") + end for _, pid in ipairs(b.pids or {}) do os.execute("kill -9 " .. tostring(pid) .. " 2>/dev/null") end + -- 杀掉自己占的显示器后必须清掉它的锁与 socket。 + -- 为什么必需:Xvfb 被 SIGKILL(本函数就是这么杀的)来不及清理 + -- /tmp/.X-lock,留着就成了**陈旧锁**。而选号逻辑是「锁/socket 存在就当被占」, + -- 于是每个被 KILL 的显示器都会永久烧掉一个号 —— 反复跑测试/反复登录, + -- 91 起始的号会被耗光,之后每次都挑中 91 并报 + -- “Server is already active for display 91”(真实踩过:41/41 全占)。 + -- 用 b.display 而不是 b.session.display:**失败路径也在 session 赋值之前就 + -- 调本函数**(Xvfb 起不来、端口未就绪…),那时只有 display 已知。 + local disp = b.display or (b.session and b.session.display) + if disp then + os.remove("/tmp/.X" .. tostring(disp) .. "-lock") + os.remove("/tmp/.X11-unix/X" .. tostring(disp)) + end + -- profile 目录也一并清:tag 是唯一的,不清就会无限累积(每次登录留一份 + -- 几十 MB 的 chromium profile 在 /tmp)。 + if b.profile then + os.execute("rm -rf " .. b.profile .. " 2>/dev/null") + end b.profile = nil + b.display = nil b.pids = {} b.session = nil end +-- xdisplayBusy(n):显示器号是否真被占。 +-- 判存在用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件,io.open 对它 +-- 返回 nil(打不开)→ 会误判“空闲”→ 又选中被占的号(这个坑真实踩过)。 +local function xdisplayBusy(n) + local lock = "/tmp/.X" .. tostring(n) .. "-lock" + local ok = os.execute('[ -e "' .. lock .. '" ] 2>/dev/null') + local hasLock = (ok == true or ok == 0) + if not hasLock then + local sok = os.execute('[ -e "/tmp/.X11-unix/X' .. tostring(n) .. '" ] 2>/dev/null') + return sok == true or sok == 0 + end + -- 锁在,但记录的 PID 可能早死了 —— 那就是陈旧锁,**可以回收**。 + -- 不回收的话号会越用越少(见 reapBrowser 注释)。 + local f = io.open(lock, "r") + if not f then return true end + local pid = (f:read("*l") or ""):gsub("%s", "") + f:close() + if pid == "" then return true end + -- 存活判定用 /proc/,**不用 os.execute("kill -0 …")**: + -- 后者靠 shell 退出码,而这个 VM 里 os.execute 的返回值形态不可靠 + -- (判据实测咬了:陈旧锁没被回收 → 号仍然占着)。 + -- /proc/ 存在即为进程存在(僵尸也在,偏保守,不会误回收活着的号)。 + if pid:match("^%d+$") then + local alive = io.open("/proc/" .. pid .. "/stat", "r") + if alive then + alive:close() + return true + end + else + return true -- 锁里不是数字(异常内容),当被占处理 + end + -- 陈旧锁:清掉它和可能残留的 socket,让这个号重新可用。 + os.remove(lock) + os.remove("/tmp/.X11-unix/X" .. tostring(n)) + return false +end + +-- pickFreeX(start, span):挑一个真正空闲的显示器号。 +-- 与旧 pickFree(91, 40) 的差别:① 陈旧锁会被回收(而不是永久占号); +-- ② 扫不通时**如实报错**,不再 `return start` —— 返回一个已知被占的号只会 +-- 让 Xvfb 报一句难以归因的 “already active for display N”。 +local function pickFreeX(start, span) + for n = start, start + span do + if not xdisplayBusy(n) then return n end + end + return nil +end + local function browserDeps() local missing, present = {}, {} for _, d in ipairs(BROWSER_DEPS) do @@ -1759,6 +3518,41 @@ end -- (能拉浏览器栈、能在磁盘上读写),读一下自己的 config.yaml 是它能力范围内的事。 -- 为了一个端口给内核加只读动作,既不必要,也污染了内核的「零厂商/零部署知识」边界。 -- 优先级:settings 显式覆盖 > config.yaml 的 listen > 环境变量 > 默认值。 +-- buildAuthorizeUrl(plat, port, state, redirectUri, vars):按平台 authorize 规格拼授权 URL。 +-- +-- 规格形如 { base = "...", params = {...}, hash = "/portal#/login" }: +-- - params 里的值支持 {port} / {redirect_uri} 占位,另由 vars 提供 +-- 平台特有的占位(如 Trae 的 machine_id / device_id / trace_id); +-- - hash 非空时把参数挂在 **hash 段**之后(LobsterAI 是 /portal#/login?…); +-- - 值一律百分号编码(redirect_uri 含 :// 与 :,不编码会被登录页拒)。 +-- 参考注释明确警告过手工拼串漏编码的坑(buildLobsteraiLoginUrl)。 +local function buildAuthorizeUrl(plat, port, state, redirectUri, vars) + local a = plat.authorize or {} + local base = a.hash or a.base or plat.login_url + local parts = {} + local function add(k, v) + v = tostring(v):gsub("{port}", tostring(port)) + if redirectUri and redirectUri ~= "" then + v = v:gsub("{redirect_uri}", redirectUri) + end + if type(vars) == "table" then + for vk, vv in pairs(vars) do + v = v:gsub("{" .. vk .. "}", tostring(vv)) + end + end + if k == "state" and (v == "" or v == "nil") then v = state end + parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v) + end + -- 确定性的顺序:排序键名,保证同一会话每次拼出的 URL 一致(便于对账/复现) + local keys = {} + for k in pairs(a.params or {}) do keys[#keys + 1] = k end + table.sort(keys) + for _, k in ipairs(keys) do add(k, a.params[k]) end + -- state 始终补上(除非 params 里已显式给了) + if not (a.params and a.params.state) then add("state", state) end + return base .. "?" .. table.concat(parts, "&") +end + local function gatewayPort() -- ① settings:运维可显式指定(前置代理 / 反代场景,对外端口与 listen 不同) local s = plugin.state.settings and plugin.state.settings.gateway_port @@ -1893,19 +3687,238 @@ local function cdpEval(cdpPort, expr) return rec.value, nil end +-- ============================================================================ +-- loopback 回调接收器(平台要求 redirect_uri 落在本机精确路径时用) +-- ============================================================================ +-- +-- 为什么必需:各回调平台的 redirect_uri 都是**回环地址**(127.0.0.1 / localhost) +-- 且路径是各自注册值(有道 /auth/callback、Trae /authorize、Gemini /oauth-callback), +-- 而本插件的挂载点在 `/api/plugins/http/…`;更关键的是**远程用户机器上的 +-- 127.0.0.1 不是本网关** —— 那样重定向根本打不到这里。 +-- +-- 做法:插件(可信、有 OS 能力)在本机起一个极小的 HTTP 监听,路径前缀就是该 +-- 平台的注册路径。**双栈绑回环**(::1 与 127.0.0.1 同一端口):参考注释点明 +-- 「浏览器常把 localhost 解析成 IPv6」,而 Gemini 注册的就是 http://localhost:… +-- —— 只绑 IPv4 会让那条回调连不上(但**绝不**绑 :: 对外暴露)。 +-- 它把回调 query 原子写进**文件**(不拼 shell,避免引号/特殊字符注入 —— +-- 与 CDP 桥用文件传表达式同一理由),然后给浏览器一个可关闭的页面。 +-- +-- 校验规则按平台形态分两类(不能一刀切): +-- * Trae:回调把 token 直接放 query(refreshToken/userJwt/…),**不带 state** +-- (参考 parseTraeCallback 从不读 state),故对 /authorize 不要求 state。 +-- * Gemini / 有道:标准授权码回调,state 必须匹配,否则 400(防伪造回调)。 +-- +-- 真正入账前的 state 终校在 poll_login(读文件后再次比对 nonce)。 +-- +-- 返回 (redirect_url, nil) 或 (nil, err)。进程 pid 记入 plugin.state.loopbacks, +-- 会话结束时由 reapLoopback 回收。 +local LOOPBACK_PY = [[ +import sys, json, os, socket, tempfile, threading, random +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse, parse_qs +prefix, out_path, want_state, port_path = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] +# Trae 的回调不带 state(它把 token 放在 query 里,靠路径区分);其余平台必须带。 +# 各平台的回调查验规则不同,**不能一刀切**(照参考逐个平台看): +# /authorize Trae:token 直接放 query,**不带 state** +# /oauth/callback CodeArts:带 code + ticket_id(无 state),旧流程回退带 secret +# 其余(有道 /auth/callback、Gemini /oauth-callback):标准授权码 + state 校验 +require_state = (prefix != "/authorize" and prefix != "/oauth/callback") +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + def do_GET(self): + u = urlparse(self.path) + if not u.path.startswith(prefix): + self.send_response(404); self.end_headers(); self.wfile.write(b"Not found"); return + q = parse_qs(u.query) + state = (q.get("state") or [""])[0] + if require_state and (not state or (want_state and state != want_state)): + # state 缺失/不匹配 → 不是本次登录发起的(或伪造),直接拒(对齐参考)。 + self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8") + self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u53c2\u6570\u65e0\u6548".encode()); return + # 把整个回调 query 原样转成 JSON(含 code/authCode 与 Trae 的 + # refreshToken/userJwt/userInfo 等),由 Lua 侧按平台形态取用。 + payload = {} + for k, v in q.items(): + payload[k] = v[0] if v else "" + code = payload.get("code") or payload.get("authCode") or "" + # codearts 旧流程回退只带 secret;authorize 只带 token —— 都不要求 code + need_code = (prefix != "/authorize" and prefix != "/oauth/callback") + if need_code and not code: + self.send_response(400); self.send_header("Content-Type","text/plain; charset=utf-8") + self.end_headers(); self.wfile.write("\u767b\u5f55\u56de\u8c03\u7f3a\u5c11\u6388\u6743\u7801".encode()); return + if not payload: + self.send_response(400); self.end_headers(); self.wfile.write(b"No params"); return + # 不经 shell 拼串,直接写成 JSON 文件(避免引号/特殊字符注入)。 + d = os.path.dirname(out_path) + fd, tmp = tempfile.mkstemp(dir=d) + with os.fdopen(fd, "w") as f: + json.dump(payload, f) + os.replace(tmp, out_path) + self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8") + self.end_headers() + self.wfile.write("

\u767b\u5f55\u6210\u529f\uff0c\u53ef\u4ee5\u5173\u95ed\u6b64\u7a97\u53e3\u4e86

".encode()) + # 双栈:两个 HTTP 实例都要主动停(否则非当前那个会挂着不退)。 + def _stop(): + for s in SERVERS: + try: s.shutdown() + except Exception: pass + threading.Thread(target=_stop, daemon=True).start() + +SERVERS = [] +# 为何要双栈:参考注释点明「浏览器常把 localhost 解析成 IPv6」—— +# 只绑 127.0.0.1 时,Gemini 的 http://localhost:…/oauth-callback 会连不上。 +# 不能改成绑 ::(那等于对外暴露);只绑回环地址的两种家族。 +# 注意:address_family 必须在**构造前**就在类上定好(socketserver 建 socket +# 时读的就是这个类属性);构造后再赋值无效 —— 那会让 IPv6 分支静默失败。 +class V6Server(HTTPServer): + address_family = socket.AF_INET6 + +# minPort:某些平台(CodeArts)要求回调端口 >= 10000,低于此值 portal 会拒绝。 +# 照参考 listenOnCallbackPort:bind(0) 拿到的端口若过小,关掉后用 [minPort,65535] +# 的随机端口重试。 +min_port = int(sys.argv[5]) if len(sys.argv) > 5 else 0 +port = 0 +try: + v6 = V6Server(("::1", 0), H) + port = v6.server_address[1] + if min_port and port < min_port: + v6.server_close() + port = 0 + else: + SERVERS.append(v6) +except Exception: + pass +if port == 0: + for _ in range(6): + cand = random.randint(min_port, 65535) if min_port else 0 + try: + v6 = V6Server(("::1", cand), H) + port = v6.server_address[1] + SERVERS.append(v6) + break + except Exception: + port = 0 +try: + v4 = HTTPServer(("127.0.0.1", port), H) + if port == 0: + port = v4.server_address[1] + SERVERS.append(v4) +except Exception: + pass +if not SERVERS: + sys.exit(3) +# 把实际选中的端口写回文件:调用方读它拼 redirect_uri(bind 0 时端口只有 bind 后才知道)。 +with open(port_path, "w") as f: + f.write(str(port)) +for s in SERVERS: + threading.Thread(target=s.serve_forever, daemon=True).start() +threading.Event().wait() +]] + +local function loopbackScriptPath() + local p = "/tmp/llmsproxy-loopback.py" + local w = io.open(p, "w") + if not w then return nil end + w:write(LOOPBACK_PY) + w:close() + return p +end + +-- reapLoopback 杀掉某会话的接收器进程并清文件(无需常驻,用完即收)。 +-- 必须定义在 loopbackReceiver **之前**:Lua 的 local 作用域只覆盖其后的代码, +-- 反过来写会让 loopbackReceiver 里的调用拿到 nil(真实踩过的坑)。 +local function reapLoopback(sessionId) + local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId] + if not l then return end + if l.pid and l.pid ~= "" then + os.execute("kill -9 " .. tostring(l.pid) .. " 2>/dev/null") + end + if l.file then os.remove(l.file) end + if l.port_file then os.remove(l.port_file) end + plugin.state.loopbacks[sessionId] = nil +end + +-- loopbackReceiver(session_id, cb_path):起接收器,返回该会话的 redirect_uri。 +-- +-- cb_path 必须与平台注册值**逐字一致**(改了就是 redirect_uri_mismatch): +-- 有道 /auth/callback(参考注释点名「精确路径」)、 +-- Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)。 +local function loopbackReceiver(sessionId, cbPath, minPort) + cbPath = cbPath or "/auth/callback" + local script = loopbackScriptPath() + if not script then return nil, "无法写入 loopback 脚本" end + local dir = storeDir() + local outFile = dir .. "/callback-" .. sessionId .. ".json" + local portFile = dir .. "/callback-" .. sessionId .. ".port" + os.remove(outFile) + os.remove(portFile) + local sess = sessions()[sessionId] + local wantState = (sess and sess.nonce) or "" + -- 端口由 python bind(0) 自己选,绑 127.0.0.1(不对外暴露监听面); + -- 它把实际端口写回 portFile,我们轮询读取后拼 redirect_uri。 + local cmd = ("python3 %s %s %s %s %s %s >/tmp/llmsproxy-loopback-%s.log 2>&1 & echo $!"):format( + script, cbPath, outFile, wantState, portFile, tostring(minPort or 0), sessionId) + local h = io.popen(cmd) + local pid = h and h:read("*l") + if h then h:close() end + if not pid or pid == "" then return nil, "loopback 启动失败(python3 不可用?)" end + if not plugin.state.loopbacks then plugin.state.loopbacks = {} end + plugin.state.loopbacks[sessionId] = { pid = pid, file = outFile, port_file = portFile } + -- 等端口文件出现(就绪探针,不用 sleep 拍脑袋) + local deadline = (os.time and os.time() or 0) + 10 + local port = nil + while true do + local f = io.open(portFile, "r") + if f then + local v = f:read("*l") + f:close() + port = tonumber(v) + if port then break end + end + if (os.time and os.time() or 0) >= deadline then + reapLoopback(sessionId) + return nil, "loopback 未就绪(未取得端口)" + end + os.execute("sleep 0.2") + end + -- 主机用 127.0.0.1(不是 localhost,除非平台注册值就是 localhost); + -- 路径按平台注册值一字不差。 + local host = (sess and sess.callback_host) or "127.0.0.1" + return "http://" .. host .. ":" .. tostring(port) .. cbPath, nil +end + +-- readLoopbackCallback(session_id):读接收器写下的回调 query。 +-- 返回 (payload_table, nil) 或 (nil, nil)。未到/文件不存在返回 nil,nil —— +-- 调用方应继续保持 pending(不能把「还没回调」当成「失败」)。 +local function readLoopbackCallback(sessionId) + local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId] + local f = l and l.file and io.open(l.file, "r") + if not f then return nil, nil end + local body = f:read("*a") + f:close() + local ok, rec = pcall(json.decode, body or "") + if not ok or type(rec) ~= "table" then return nil, nil end + return rec, nil +end + +-- ============================================================================ -- spawnBrowser 拉起一组服务端浏览器。返回 --- { view_url, password, cdp_port, display, vnc, ws } 或 nil, err。 --- start_url 是浏览器要打开的登录页;callback_path 是拼 redirect_uri 用的路径。 +-- { view_url, password, cdp_port, display, vnc, ws } 或 nil, err。 -- -- 与早期实现的差别(都是真实踩过的坑): --- 1. **就绪探针**:不再 sleep 2 拍脑袋,各端口逐个等(X 起来 → 浏览器起 → --- VNC 起 → websockify 起),否则 view_url 先于服务可用,iframe 白屏。 --- 2. **CDP 端口**:浏览器带 --remote-debugging-port,登录完成后可读凭据 --- (见 cdpEval / vncCollect),而不是只能人眼看屏幕。 --- 3. **网关端口来自内核**:回调地址拼的是本实例真实端口,不硬编码 8081。 --- 4. **依赖预检**:缺依赖直接给出清单(谁、干什么用、装什么包), --- 而不是一个含糊的"进程启动失败"。 -local function spawnBrowser(start_url, callback_path) +-- 1. **就绪探针**:不再 sleep 2 拍脑袋,各端口逐个等(X 起来 → 浏览器起 → +-- VNC 起 → websockify 起),否则 view_url 先于服务可用,iframe 白屏。 +-- 2. **CDP 端口**:浏览器带 --remote-debugging-port,登录完成后可读凭据 +-- (见 cdpEval / vncCollect),而不是只能人眼看屏幕。 +-- 3. **网关端口来自内核**:回调地址拼的是本实例真实端口,不硬编码 8081。 +-- 4. **依赖预检**:缺依赖直接给出清单(谁、干什么用、装什么包), +-- 而不是一个含糊的"进程启动失败"。 +-- spawnBrowser(start_url):拉起服务端浏览器栈(Xvfb + chromium + x11vnc + +-- websockify),返回 VNC 视图地址与 CDP 端口。 +-- start_url 已是**拼装完整的授权页 URL**(redirect_uri/state 由调用方按平台 +-- 规格填好),此处不再改写。 +local function spawnBrowser(start_url) local b = browserStack() if b.session then return nil, "已有浏览器会话在跑,请先停止" end local deps = browserDeps() @@ -1918,9 +3931,15 @@ local function spawnBrowser(start_url, callback_path) end b.seq = b.seq + 1 local seq = b.seq + -- 每次会话一个唯一标记:用于 chromium 的 user-data-dir 与日志文件名。 + -- 为何不能用 seq:seq 在插件重启后从 1 重新开始,于是会**复用同一个 + -- user-data-dir**,而里面残留的 `SingletonLock` 会让 chromium 认为 + -- “已有实例在跑”——它只打印一句 `Opening in existing browser session.` 就退出, + -- 新端口永不监听 → 15s 后报「chromium 端口未就绪」(真实踩过)。 + -- 显示器锁是同一类坑(见 pickFreeX),此处用不可复用的标记从根上避开。 + local tag = tostring(os.time and os.time() or 0) .. "-" .. newToken():sub(1, 8) -- display / 端口取号:**探测空闲**,不能按 seq 取模。 - -- - -- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X-lock)在 + -- -- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X-lock)在 -- 进程被强杀时会残留。重启后 seq 从头开始 → 又选中同一个号 → Xvfb 直接 -- `Fatal: Server is already active for display 91` 退出,VNC 兜底永远起不来, -- 而报错只显示"Xvfb 未就绪",看不出是被残留锁挡住。 @@ -1940,10 +3959,16 @@ local function spawnBrowser(start_url, callback_path) -- Lua 5.1 返回数字(0=成功),5.2+ 返回 true/nil return ok == true or ok == 0 end - local disp = pickFree(91, function(n) - return not pathExists("/tmp/.X" .. n .. "-lock") - and not pathExists("/tmp/.X11-unix/X" .. n) - end) + -- 显示器号:用 pickFreeX(会回收陈旧锁,且扫不到就如实报错), + -- 不再用“存在就当被占”的 inline 判定 —— 那会把每个被 KILL 的显示器永久烧掉。 + local disp = pickFreeX(91, 200) + if not disp then + return nil, "没有可用的显示器号(/tmp/.X*-lock 全被占用)" + end + -- 立即记下:后续任何一步失败都会 reapBrowser(),它靠 b.display 才能清掉 + -- 这个号的锁(否则 Xvfb 未起来就失败时,残留锁会把号白烧掉)。 + local bs = browserStack() + bs.display = disp local function portFree(p) -- 用 /proc/net/tcp + tcp6 查是否有 LISTEN(十六进制端口) for _, f in ipairs({ "/proc/net/tcp", "/proc/net/tcp6" }) do @@ -1966,10 +3991,12 @@ local function spawnBrowser(start_url, callback_path) -- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次 local pass = sha256_hex(newToken()):sub(1, 16) local gwPort = gatewayPort() - -- 端口占位换成网关真实回环地址(浏览器在服务端,回调直接命中网关挂载点) - local url = start_url:gsub("redirect_uri=127%.0%.0%.1:%%d+", - "redirect_uri=127.0.0.1:" .. gwPort .. callback_path) - + -- 不再改写 start_url 里的端口/回调地址:`{port}` 占位已在 buildAuthorizeUrl + -- 拼装时换成 gatewayPort(),而各平台的回调路径**彼此不同**(Trae 用 + -- auth_callback_url 指本插件挂载点、LobsterAI 用 /auth/callback 指 loopback + -- 接收器)。此前这里用 gsub 把 "redirect_uri=127.0.0.1:" 一律改写成 + -- /api/plugins/http/channels/callback —— 一旦命中就会把需要别的路径的平台 + -- (尤其有道,其 login 段还被百分号编码)改错。现为死代码且只可能致害,遂删。 -- 每步起一个进程并等它的端口就绪(X 用 socket 探,别的用 TCP)。 local pids = {} local function spawn(name, cmd, host, port, timeoutMs) @@ -1995,17 +4022,28 @@ local function spawnBrowser(start_url, callback_path) local chromium = deps.chromium local novnc = deps.novnc + -- chromium 数据目录必须**每次会话全新的**:复用同一目录时,上一次被强杀 + -- 留下的 SingletonLock 会让它直接退出(见 tag 的注释)。这里不只是换新目录, + -- 还把可能残留的单例锁与陈旧 profile 清掉,双保险。 + local profileDir = "/tmp/llmsproxy-ch-" .. tag + os.execute("rm -rf " .. profileDir .. " 2>/dev/null") + -- 顺带清掉**陈旧**的 profile 目录:正常路径由 reapBrowser 清,但进程被强杀 + -- (go test 超时、插件崩)时来不及清,而 tag 唯一意味着它们会一直累积。 + -- 只清 6 小时前的,不会碰到别的会话正在用的。 + os.execute("find /tmp -maxdepth 1 -name 'llmsproxy-ch-*' -mmin +360 -exec rm -rf {} + 2>/dev/null") + -- 记下来供 reapBrowser 收尾清理(失败路径也要清,否则 tag 唯一=无限累积)。 + browserStack().profile = profileDir local steps = { - { "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%d.log 2>&1 & echo $!"):format(disp, seq), nil, 0, 0 }, - { "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=/tmp/llmsproxy-ch-%d " + { "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%s.log 2>&1 & echo $!"):format(disp, tag), nil, 0, 0 }, + { "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s " .. "--no-first-run --disable-gpu --remote-debugging-port=%d --remote-allow-origins=* " - .. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%d.log 2>&1 & echo $!"):format( - chromium, seq, cdpPort, disp, url, seq), "127.0.0.1", cdpPort, 15000 }, + .. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format( + chromium, profileDir, cdpPort, disp, url, tag), "127.0.0.1", cdpPort, 15000 }, { "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet " - .. ">/tmp/llmsproxy-x11vnc-%d.log 2>&1 & echo $!"):format( - disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, seq), "127.0.0.1", vport, 8000 }, - { "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%d.log 2>&1 & echo $!"):format( - novnc, bind, wport, vport, seq), bind, wport, 8000 }, + .. ">/tmp/llmsproxy-x11vnc-%s.log 2>&1 & echo $!"):format( + disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, tag), "127.0.0.1", vport, 8000 }, + { "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%s.log 2>&1 & echo $!"):format( + novnc, bind, wport, vport, tag), bind, wport, 8000 }, } -- ① 先起 Xvfb(其余三步依赖 X display) local xvfbStep = steps[1] @@ -2054,20 +4092,24 @@ end -- ============================================================================ -- start_login 按优先级阶梯响应: --- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在 --- 用户自己的机器上,插件轮询/收回调拿凭据) --- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调), --- 无需用户之外的动作 --- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、 --- 回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。 -function plugin.start_login(req) - local args = bodyOf(req) - local plat = platformById(args and args.platform) - if not plat then - return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } } - end - sweepSessions() - local now = os.time and os.time() or 0 +-- ① 本机浏览器:直接返回登录 URL(绝大多数场景到此为止 —— 平台重定向打在 +-- 用户自己的机器上,插件轮询/收回调拿凭据) +-- ② 自动化:link-poll/callback 流程本身就是自动的(轮询 + nonce 回调), +-- 无需用户之外的动作 +-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、 +-- 注意回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。 +-- ③ VNC 兜底:仅 flow == "vnc" 且 ①② 无法完成的平台(无浏览器系统、 +-- 注意回环重定向必须落在服务端)。此时插件自己拉起服务端浏览器栈。 +-- +-- 有道 LobsterAI:portal 授权页要求 redirect_uri 是 +-- http://127.0.0.1:{port}/auth/callback(**精确路径**,参考注释点名)。 +-- 远程用户机器上的 127.0.0.1 **不是本网关** —— portal 重定向打不到本插件的 +-- /api/plugins/http/... 挂载点。故插件(可信、有 OS 能力)在本机起一个 +-- 轻量的 loopback 接收器,portal 重定向落到它的本地端口;它把 authCode/state +-- 写进一个文件,poll_login 读该文件完成交换。这样远程用户在服务端浏览器(VNC) +-- 里完成交互后,portal 直接重定向到该本机端口,无需远程浏览器与网关同机。 + +local function initLoginSession(plat, args, now) local sess = { id = newToken():sub(1, 24), platform = plat.id, @@ -2078,8 +4120,35 @@ function plugin.start_login(req) created_at = now, expires_at = now + SESSION_TTL, } + if plat.id == "lobsterai" then + local seed = newToken() + sess.install_uuid = seed:sub(1, 8) .. "-" .. seed:sub(9, 12) .. "-4" + .. seed:sub(14, 16) .. "-8" .. seed:sub(18, 20) .. "-" .. seed:sub(21, 32) + sess.first_keyfrom = tostring(now * 1000) + elseif plat.id == "trae" then + -- 设备身份:参考生成两个 **hex32**(照 login.sh 的 `openssl rand -hex 16`), + -- 并在登录 URL 里下发。凭据里也要留一份(签到 X-Device-Id 用), + -- 且**每账号必须不同** —— 同一天两账号共用会被「该设备已签到」拦。 + -- 此前只发 5 个参数、完全没造这两个值,登录页就停在「授权中」不回来。 + sess.machine_id = newToken():sub(1, 32) + sess.device_id = newToken():sub(33, 64) + local joined = sess.machine_id .. sess.device_id + sess.login_trace_id = #joined >= 16 and joined:sub(-16) or joined + end sessions()[sess.id] = sess - sess.account_id = args and args.account_id -- 账号池路径:登录完成回填这个池账号 + sess.account_id = args and args.account_id + return sess +end + +function plugin.start_login(req) + local args = bodyOf(req) + local plat = platformById(args and args.platform) + if not plat then + return { status = 400, json = { error = "未知平台: " .. tostring(args and args.platform) } } + end + sweepSessions() + local now = os.time and os.time() or 0 + local sess = initLoginSession(plat, args, now) logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")") -- 响应里只带一次 nonce(回调校验要用),存侧不回显 @@ -2088,6 +4157,50 @@ function plugin.start_login(req) state = sess.state, nonce = sess.nonce, expires_at = sess.expires_at }, platform = plat.id, } + if plat.id == "lobsterai" or plat.id == "trae" or plat.id == "gemini" or plat.id == "codearts" then + -- 这三个平台的 redirect_uri 都是**回环地址**(127.0.0.1 / localhost), + -- 且路径各自是注册值:有道 /auth/callback、Trae /authorize、 + -- Gemini /oauth-callback。回环地址由「打开授权页的那台机器」解析,而 + -- 服务端浏览器就在本机 → 重定向落本机 → 插件自己的接收器能接住它。 + -- 远程用户在自己电脑上打开则永远接不住(这正是 callback 流在服务器 + -- 部署下必须走服务端浏览器的原因)。 + local cbPath = (plat.authorize and plat.authorize.callback_path) + or (plat.id == "gemini" and "/oauth-callback") + or (plat.id == "trae" and "/authorize") or "/auth/callback" + -- Gemini 注册的 redirect_uri 主机是 **localhost**(不是 127.0.0.1), + -- 参考注释明确点名;两者在多数机器上等价,但必须与注册值逐字一致。 + if plat.id == "gemini" then sess.callback_host = "localhost" end + -- CodeArts:生成 PKCE 与 ticket_id,回调端口须 ≥10000(portal 拒绝低端口), + -- 登录 URL 用**专用构建函数**(含 SHA-256 而非 S256,且不得有 auth_callback_url)。 + if plat.id == "codearts" then + local pkce, perr = codeartsPkcePair() + if not pkce then + sess.state = "failed" + out.error = "PKCE 生成失败: " .. tostring(perr) + return { status = 200, json = out } + end + local key, kerr = codeartsNewDpop() + if not key then + sess.state = "failed" + out.error = "DPoP 密钥生成失败: " .. tostring(kerr) + return { status = 200, json = out } + end + sess.codearts = { pkce = pkce, key = key, ticket_id = newToken():sub(1, 64) } + sess.min_port = (plat.authorize and plat.authorize.min_port) or 10000 + end + local minPort = nil + if plat.id == "codearts" then + minPort = (plat.authorize and plat.authorize.min_port) or 10000 + end + local cb_url, cb_err = loopbackReceiver(sess.id, cbPath, minPort) + if cb_err then + sess.state = "failed" + out.error = "无法启动回调接收器: " .. cb_err + return { status = 200, json = out } + end + sess.redirect_uri = cb_url + out.redirect_uri = cb_url + end if plat.flow == "manual" then -- 无法自动化的登录(WASM/captcha/扫码):给明确说明而不是假装成功。 sess.state = "failed" @@ -2121,7 +4234,7 @@ function plugin.start_login(req) logEvent("state 已申请 " .. plat.id) elseif plat.flow == "vnc" then -- ③ 兜底路径:服务端浏览器。重定向打在服务端回环上,回调命中本插件的挂载点。 - local bs, berr = spawnBrowser(string.format(plat.login_url, 0), "/api/plugins/http/channels/callback") + local bs, berr = spawnBrowser(string.format(plat.login_url, 0)) if berr then out.browser_error = berr out.hint = "服务端浏览器不可用;运行 channels/deps/status 看缺什么依赖" @@ -2162,9 +4275,9 @@ function plugin.start_login(req) end local html = resp.body or "" -- 两条路径互为兜底(照参考 extractLoomyWechatUuid 的正则): - -- 主: - -- 兜底:var fordevtool = "…/connect/l/qrconnect?uuid=" - -- ⚠️ 无需执行 JS —— 这是本方案能脱离浏览器的基础。 + -- 主: + -- 兜底:var fordevtool = "…/connect/l/qrconnect?uuid=" + -- 关键:无需执行 JS —— 这是本方案能脱离浏览器的基础。 local uuid = html:match("/connect/qrcode/([%w_%-=+/]+)") or html:match("l/qrconnect%?uuid=([%w_%-=+/]+)") if not uuid or uuid == "" then @@ -2219,14 +4332,13 @@ function plugin.start_login(req) logEvent("授权流程已初始化 " .. plat.id .. " flow=" .. tostring(data.flow_id):sub(1, 12)) elseif plat.flow == "device-poll" then -- 设备码:申请码,把 user_code + 授权 URL 给用户,verifier 存进会话。 - -- - -- 两种形态: - -- ① OAuth 设备码(Minimax/Cline):POST 拿 device_code + user_code, - -- 再 GET token_url?device_code=... 轮询(code_body / token_body 模板)。 - -- ② PKCE 授权页(Qoder):**不申请设备码** —— 本地造 PKCE,直接把 - -- {authBase}/...?challenge&nonce&machine_id&client_id 给用户当授权页, - -- 再轮询 token_url?nonce&verifier 由服务端"回填"token(404=继续)。 - -- 此时 code_url 带 Q_QUERY 后缀表示"GET + query 模板",无 device_code。 + -- -- 两种形态: + -- ① OAuth 设备码(Minimax/Cline):POST 拿 device_code + user_code, + -- 再 GET token_url?device_code=... 轮询(code_body / token_body 模板)。 + -- ② PKCE 授权页(Qoder):**不申请设备码** —— 本地造 PKCE,直接把 + -- {authBase}/...?challenge&nonce&machine_id&client_id 给用户当授权页, + -- 再轮询 token_url?nonce&verifier 由服务端"回填"token(404=继续)。 + -- 此时 code_url 带 Q_QUERY 后缀表示"GET + query 模板",无 device_code。 local verifier, challenge = devicePkce() local machineId = newToken():sub(1, 32) local dm = plat.device.code_method or "POST" @@ -2276,9 +4388,71 @@ function plugin.start_login(req) out.session.interval = sess.device_interval logEvent("设备码已申请 " .. plat.id .. " user_code=" .. tostring(rec.user_code)) else - -- ①②:URL 交给用户自己的浏览器;callback 流程的 redirect_uri 指向本插件挂载点 + -- ①②:URL 交给用户自己的浏览器。 + -- + -- callback 流程的授权 URL 构造有三条硬性要求(此前都错): + -- 1) **每个平台的参数集不同**,不能套通用模板:Trae 用 auth_callback_url + -- (不是 redirect_uri)、Gemini 用标准 OAuth 四参数、LobsterAI 的 login + -- 在 **hash 段**(/portal#/login)。 + -- 2) 端口必须用**本机实际监听端口**,不能硬编码 8081(非默认部署会全错)。 + -- 3) redirect_uri 必须百分号编码(含 :// 与 :,不编码会被授权页拒)。 + local port = gatewayPort() if plat.flow == "callback" then - out.url = plat.login_url .. "&redirect_uri=" .. "127.0.0.1:8081/api/plugins/http/channels/callback&state=" .. sess.nonce + if plat.id == "codearts" and sess.codearts then + -- 用回调服务器**实际监听的端口**(≥10000),而不是网关端口 —— + -- portal 凭 port 参数构造回调地址,错了就收不到 code。 + local cbPort = tonumber(sess.redirect_uri and sess.redirect_uri:match(":(%d+)/")) + out.url = buildCodeartsAuthorizeUrl(cbPort or port, sess.codearts.pkce, + sess.codearts.ticket_id) + elseif type(plat.authorize) == "table" then + local vars + if sess.machine_id then + vars = { machine_id = sess.machine_id, device_id = sess.device_id, + trace_id = sess.login_trace_id } + end + out.url = buildAuthorizeUrl(plat, port, sess.nonce, sess.redirect_uri, vars) + else + local cb = "http://localhost:" .. tostring(port) .. "/api/plugins/http/channels/callback" + local sep = plat.login_url:find("?", 1, true) and "&" or "?" + out.url = plat.login_url .. sep .. "redirect_uri=" .. rfc3986Escape(cb) + .. "&state=" .. sess.nonce + end + -- callback 流的 redirect_uri 是**回环地址**(127.0.0.1 / localhost)。 + -- 回环地址由「打开授权页的那台机器」解析:用户在自己电脑上打开时,重定向 + -- 落在**用户机器**,回调永远到不了本网关 —— 这是服务器部署下 callback 流 + -- 无法靠「本机浏览器」完成的根本原因(CodeBuddy 能用的原因是它走 + -- state-poll,根本不需要回调)。 + -- + -- 故 callback 流要走**服务端浏览器**:授权页在网关本机打开(经 VNC 呈现给 + -- 运维交互),重定向落本机回环,回调命中本插件的 loopback 接收器/挂载点。 + -- 没有浏览器栈时**如实说明**,不假装这个 URL 在用户机器上能用。 + local deps = browserDeps() + if deps.ok then + -- 单会话栈:起新的之前先收掉旧的(否则逐次登录会叠加多套进程)。 + local cur = browserStack() + if cur.session then reapBrowser() end + cur.owner = sess.id + sess.owns_browser = true + local bs, berr = spawnBrowser(out.url) + if not berr then + out.view_url = bs.view_url + out.session.cdp_port = bs.cdp_port + sess.cdp_port = bs.cdp_port + sess.collect_expr = plat.collect_expr or plat.callback_collect_expr + out.server_browser = true + logEvent("服务端浏览器已就绪 " .. plat.id .. " (CDP :" .. tostring(bs.cdp_port) .. ")") + else + out.browser_error = berr + logEvent("服务端浏览器启动失败 " .. plat.id .. ": " .. berr) + end + else + local miss = {} + for _, m in ipairs(deps.missing) do miss[#miss + 1] = m.bin end + out.browser_error = "缺浏览器栈依赖: " .. table.concat(miss, ", ") + end + out.hint = out.server_browser + and "请在 VNC 窗口内完成授权(回调会落在网关本机)" + or "callback 流的回调是回环地址,只有与网关同机的浏览器才能完成;请先安装浏览器栈(channels/deps/status 可查缺什么)" else out.url = plat.login_url end @@ -2286,25 +4460,240 @@ function plugin.start_login(req) return { status = 200, json = out } end +-- lobsteraiClientVersion(plat):解析有道客户端版本号(带缓存与兜底)。 +-- +-- 三个消费点都要它:exchange 的 `version`、续期请求体的 `version`、签到 query。 +-- 集中一处避免各自拉取。照参考 LobsteraiVersionResolver: +-- - 远程成功 → 用远程值并缓存; +-- - 失败 → 用兜底值但**不缓存**(缓存会让一次瞬时故障在 TTL 内持续发酵)。 +-- **绝不能写死**:实测 2026-10-13 上游已是 2026.9.23。 +lobsteraiClientVersion = function(plat) + local cv = plat.client_version + if type(cv) ~= "table" then return "2026.9.4" end + local cache = plugin.state.clientVersion + local now = os.time and os.time() or 0 + if type(cache) == "table" and cache.value and cache.at + and (now - cache.at) < (cv.ttl or 3600) then + return cache.value + end + local resp, err = __llmsproxy_http({ + url = cv.url, method = "GET", + headers = { ["Accept"] = "application/json", ["User-Agent"] = "LobsterAI/0.1.0" }, + }) + if not err and resp and resp.status == 200 then + local ok, rec = pcall(json.decode, resp.body or "") + local v = ok and rec + -- 按 path(data.value.version)逐层下钻 + for seg in tostring(cv.path or ""):gmatch("[^%.]+") do + if type(v) ~= "table" then v = nil break end + v = v[seg] + end + -- 格式校验(照参考的日期式版本号正则):主干点分数字,可选预发布后缀。 + -- 不校验而直接采信:上游返 HTML/空值时会拼出一个更费解的失败。 + if type(v) == "string" and v:match("^%d+[%d%.]*[%w%.%-]*$") then + plugin.state.clientVersion = { value = v, at = now } + return v + end + logEvent("有道版本号格式异常,改用兜底: " .. tostring(v)) + end + -- 失败:兜底且不缓存 + return cv.fallback or "2026.9.4" +end + +-- lobsteraiExchange(plat, code, s):用 authCode 换有道凭据。 +-- +-- 同一段交换逻辑有两处入口:① 回调落到本插件挂载点(oauth_callback), +-- ② 回调落到本机 loopback 接收器、由 poll_login 读文件推进。两条路必须 +-- 逐字一致 —— 否则改一处漏一处,正是 go:embed 三连坑那种「同一 bug 多副面孔」。 +-- 故集中在此,返回 (credential_json, account, err)。 +local function lobsteraiExchange(plat, code, s) + if type(code) ~= "string" or code == "" then return nil, nil, "missing authCode" end + local now = tostring(os.time() * 1000) + local resp, err = __llmsproxy_http({ + url = plat.callback.token_url, method = "POST", + headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json", + ["User-Agent"] = "LobsterAI/0.1.0" }, + body = json.encode({ authCode = code, firstKeyfrom = s.first_keyfrom, + latestKeyfrom = now, uuid = s.install_uuid, version = lobsteraiClientVersion(plat) }), + }) + if err or not resp or resp.status ~= 200 then + return nil, nil, "LobsterAI exchange 请求失败: " .. tostring(err or (resp and resp.status)) + end + local ok, rec = pcall(json.decode, resp.body or "") + local d = ok and type(rec) == "table" and rec.data + if not (rec and type(rec) == "table" and rec.code == 0 and type(d) == "table" + and type(d.accessToken) == "string" and d.accessToken ~= "") then + -- 把上游的原因带出来(实测失败形如 code=40102 message="无效或已过期的授权码"), + -- 否则运维只看到「交换失败」而无法区分是码过期、参数错还是版本被拒。 + local why = "" + if ok and type(rec) == "table" then + why = tostring(rec.msg or rec.message or ("code=" .. tostring(rec.code))) + end + return nil, nil, "LobsterAI exchange 失败: " .. why + end + local u = type(d.user) == "table" and d.user or {} + local function nonempty(v) return type(v) == "string" and v ~= "" and v or nil end + -- uid 四级回退(照参考 resolveLobsteraiUid):user.id → userId → yid → token 摘要 + local uid = nonempty(u.id) or nonempty(u.userId) or nonempty(u.yid) + or sha256_hex(d.accessToken):sub(1, 16) + local account = nonempty(u.nickname) or uid + -- uuid/first_keyfrom 必须随凭据保存:续期请求体要原样回传(参考注释点名)。 + local cred = { access_token = d.accessToken, refresh_token = d.refreshToken or "", + uid = uid, user_id = nonempty(u.userId) or nonempty(u.yid) or "", nickname = account, + uuid = s.install_uuid, first_keyfrom = s.first_keyfrom, latest_keyfrom = now } + local exp = tonumber(d.expiresIn) + if exp and exp > 0 then cred.expires_at = tostring(os.time() * 1000 + exp * 1000) + else cred.expires_at = credentialExpiresAtMs(cred) end + return json.encode(cred), account, nil +end + -- plugin.poll_login(session_id) 由页面轮询:会话是否完成。 -- -- 对 device-poll 会话,每次轮询**推进一步**设备码换 token(deviceOneStep): -- 轮询由页面驱动而不在 Lua 里阻塞,因为插件是单 VM 单锁,阻塞循环会卡死 -- 本插件全部钩子。成功则落凭据+接入源(走 complete_login)。 -- --- ⚠️ **必须把 session 上的 account_id 传给 complete_login**(真实缺陷): +-- 关键:**必须把 session 上的 account_id 传给 complete_login**(真实缺陷): -- 页面流程是「先 account/add 建占位账号(credentialRef=channels::) -- → login 带 account_id → 轮询」。若这里不带 account_id,complete_login 会走 -- 兼容旧路径,凭据落到 `channels:` 这个**与占位账号无关的键**,而账号池 -- 条目的 credentialRef 指向 `channels::` —— 于是 account/list 报 -- has_credential=false、credits/balances 报「无凭据」,表现为「登录成功但无法 -- 获取账号信息」。对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义。 +-- 前向声明:这些实现都在本文件下方,而 poll_login / oauth_callback(更早的代码) +-- 就要调用它们。Lua 的 local 作用域看不到其后的定义,所以必须在**第一个调用点 +-- 之前**占坑。此前它们是全局函数,靠「全局表在运行时才解析」侥幸工作; +-- 改成 local 后这个隐式依赖就断了 —— 表现为 +-- attempt to call global 'traeCallback' (a nil value)。 +local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId +local traeCheckinHeaders, traeFixNickname + function plugin.poll_login(req) local id = (req and req.query and req.query.id) or "" sweepSessions() local s = sessions()[id] if not s then return { status = 200, json = { state = "gone" } } end + -- 回调流(callback)的凭据推进:授权页在**服务端浏览器**里打开,平台把 + -- 授权结果重定向回**本机回环地址**,由插件自己的 loopback 接收器(路径按各 + -- 平台注册值:有道 /auth/callback、Trae /authorize、Gemini /oauth-callback) + -- 接住并写下 {code,state}。这里读它并完成交换。 + -- + -- 为什么要服务端浏览器:redirect_uri 是回环地址,由「打开授权页的那台机器」 + -- 解析。远程用户在自己电脑上打开时,重定向落在**用户机器**上,永远到不了 + -- 本网关。CodeBuddy 之所以一直能用,是因为它走 state-poll,根本不需要回调。 + if s.flow == "callback" and s.state == "pending" and s.redirect_uri then + local cb, _ = readLoopbackCallback(s.id) + local gotState = cb and (cb.state or "") + local code = cb and (cb.code or cb.authCode or "") + -- Trae 的回调不带 state(token 直接放在 query 里);CodeArts 也只带 code + -- (无 state)—— 两者都不校验 state。其余平台必须 state 匹配。 + local stateOK = (s.platform == "trae") or (s.platform == "codearts") + or (gotState ~= "" and gotState == s.nonce) + -- 只有「回调真到了且 state 对」才推进;文件还没出现时保持 pending, + -- 绝不能把「用户还没操作完」当成失败。 + if cb and stateOK then + local plat = platformById(s.platform) + local credential, account, cerr + if s.platform == "codearts" then + -- CodeArts 新式 IAM OAuth:回调带 code → 走 STS 换 AK/SK(带 DPoP); + -- 旧流程回退:回调带 secret → 轮询 ticket 端点。 + local ca = s.codearts or {} + local secret = cb and (cb.secret or "") + if secret ~= "" then + local credTbl, terr = codeartsTicketPoll(ca.ticket_id, secret, + CODEARTS_LOGIN_TICKET_NAME or nil, nil) + if not credTbl then + cerr = terr + else + credential, account = json.encode(credTbl), (credTbl.user_name or "codearts") + end + elseif code ~= "" and ca.pkce and ca.key then + local cbPort = tonumber(s.redirect_uri and s.redirect_uri:match(":(%d+)/")) or 0 + local token, terr = codeartsRequestToken({ + client_id = CODEARTS_CLIENT_ID, + code = code, + code_verifier = ca.pkce.verifier, + grant_type = "authorization_code", + redirect_uri = "http://127.0.0.1:" .. tostring(cbPort) .. CODEARTS_REDIRECT_PATH, + }, ca.key) + if not token then + cerr = terr + else + local cred = codeartsCredentialFrom(token, ca.pkce, ca.key) + credential = json.encode(cred) + account = ca.ticket_id and ("codearts-" .. tostring(ca.ticket_id):sub(1, 8)) or "codearts" + end + else + cerr = "回调既无 code 也无 secret(无法完成 CodeArts 登录)" + end + elseif s.platform == "lobsterai" then + -- 有道:authCode → exchange(与 oauth_callback 路径共用同一助手,防漂移) + credential, account, cerr = lobsteraiExchange(plat, code, s) + elseif s.platform == "trae" then + -- Trae:回调把 token 直接放在 query 里(refreshToken/userJwt/userInfo), + -- 故把整个回调 query 交给 traeCallback(它在两条路径上共用)。 + credential, account, cerr = traeCallback(plat, cb, s) + if not credential and code ~= "" then + -- 回调只给了 code(PKCE 形态)→ 用 exchange 端点的标准交换 + local body = (plat.callback.exchange_body or "") + :gsub("{client_id}", plat.callback.client_id or "") + :gsub("{code}", code) + :gsub("{port}", tostring(gatewayPort())) + local resp, err = __llmsproxy_http({ url = plat.callback.exchange_url, + method = "POST", headers = { ["Content-Type"] = "application/json" }, body = body }) + if err or not resp or resp.status ~= 200 then + cerr = "Trae 授权码交换失败: " .. tostring(err or (resp and resp.status)) + else + local ok, rec = pcall(json.decode, resp.body or "") + local access = ok and type(rec) == "table" + and (rec.Token or rec.token or rec.AccessToken or rec.access_token) + if not access then cerr = "Trae 授权码交换响应缺少 Token" + else credential, account, cerr = json.encode({ access_token = access }), "trae", nil end + end + end + else + -- Gemini 等标准 OAuth:表单交换(必带 client_secret,见 token_body 注释) + local body = (plat.callback.token_body or "") + :gsub("{code}", code) + :gsub("{port}", tostring(gatewayPort())) + :gsub("{verifier}", s.device_verifier or "") + local resp, err = __llmsproxy_http({ + url = plat.callback.token_url, method = "POST", + headers = { ["Content-Type"] = "application/x-www-form-urlencoded", + ["Accept"] = "application/json" }, + body = body, + }) + if err or not resp or resp.status ~= 200 then + cerr = "token 交换失败: " .. tostring(err or (resp and resp.status)) + if resp and resp.body then cerr = cerr .. "(" .. tostring(resp.body):sub(1, 160) .. ")" end + else + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" or not rec.access_token then + cerr = "token 交换响应缺少 access_token" + else + credential, account = json.encode(rec), "oauth" + end + end + end + reapLoopback(s.id) + if cerr then + s.state = "failed" + s.message = cerr + else + local res = plugin.complete_login({ platform = s.platform, credential = credential, + account = account, account_id = s.account_id }) + if res.json and (res.json.ok or res.json.account_id) then + s.state = "completed" + s.account = account + else + s.state = "failed" + s.message = (res.json and res.json.error) or "接入源失败" + end + end + end + end + -- 条件不能要求 device_code:Qoder 形态是 PKCE 授权页,**没有 device_code** -- (本地造 verifier/challenge,服务端按 nonce 找回待授权会话)。 -- 用「有 device_code 或有 device_verifier」表达"这是个可推进的设备码/授权页会话"。 @@ -2320,7 +4709,7 @@ function plugin.poll_login(req) s.state = "failed" s.message = rerr else - -- ⚠️ 必须带 account_id:页面流程先 account/add 建了占位账号, + -- 必须带 account_id:页面流程先 account/add 建了占位账号, -- 不带就会落到与占位账号无关的 `channels:` 键(见 poll_login 顶部注释)。 local res = plugin.complete_login({ platform = s.platform, credential = cred, account = acct or payload.account, account_id = s.account_id }) @@ -2548,9 +4937,9 @@ function plugin.poll_login(req) end end -- ① 组合完整凭据 JSON(对齐参考 buildCredential):带 refresh_token / - -- expires_at / domain / user_id / nickname,而非只存裸 access_token。 - -- 只存裸 token 会丢掉 refresh_token,导致 refreshable=false、无法静默 - -- 续期,且 expires_at 未知。 + -- expires_at / domain / user_id / nickname,而非只存裸 access_token。 + -- 只存裸 token 会丢掉 refresh_token,导致 refreshable=false、无法静默 + -- 续期,且 expires_at 未知。 local credTable = buildCredential(data, accountData) local credJson = json.encode(credTable) local acct = credTable.nickname @@ -2559,7 +4948,7 @@ function plugin.poll_login(req) end if acct == nil or acct == "" then acct = "codebuddy" end -- ② 必须带 account_id(页面流程的占位账号);否则凭据落到无关的 - -- `channels:` 键,账号池查不到。 + -- `channels:` 键,账号池查不到。 local res = plugin.complete_login({ platform = s.platform, credential = credJson, account = acct, account_id = s.account_id, expires_at = credTable.expires_at, refreshable = credentialIsRefreshable(credTable) }) @@ -2667,10 +5056,13 @@ function plugin.complete_login(req) base_url = plat.base_url, adapter = "openai", api_key = legacyTok, - max_concurrent = 2, + -- 同 syncAccountSource:串行 + 节流(见那里的长注释) + max_concurrent = 1, + rpm = plat.rpm or 20, models = (function() local ms = {} - for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end + local eff = effectiveModels(plat.id) + for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), }, @@ -2698,17 +5090,26 @@ function plugin.claim_daily(req) if args.platform == nil then args = req end local plat = platformById(args and args.platform) if not plat then return { status = 400, json = { error = "未知平台" } } end - if not plat.checkin or not plat.checkin.url then + -- 有 claim_fn 的平台即使没声明 checkin 端点也能领(如 codearts 的 4 步流程、 + -- 有道的 3 步活动领取 —— 它们自己发多个请求,不走 checkin 表驱动)。 + -- 此前这里硬要求 plat.checkin,导致这两个平台的钩子**永远走不到**。 + if (not plat.checkin or not plat.checkin.url) and not plat.claim_fn then return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } } end -- 账号池路径:claim 某个池账号;否则旧路径单凭据。 - local tok, acctEntry + -- credArgs 要一并交给 claim_fn:Trae 的签到头要用凭据里的 + -- uid/access_token 派生设备身份(仅凭裸 token 拿不到这些字段)。 + local tok, acctEntry, credArgs if args.account_id and args.account_id ~= "" then acctEntry = poolGet(plat.id, args.account_id) if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end + local _, _, rec = credentialOf(acctEntry.credentialRef) tok = credentialOf(acctEntry.credentialRef) + credArgs = { account_id = args.account_id, credential = rec } else + local _, _, rec = credentialOf("channels:" .. plat.id) tok = credentialOf("channels:" .. plat.id) + credArgs = { credential = rec } end if not tok then return { status = 400, json = { error = "未登录" } } end @@ -2716,17 +5117,34 @@ function plugin.claim_daily(req) -- 参考实现的特殊形状(SDK-HMAC-SHA256 签名等)由该平台的 claim 钩子生成 -- (见 PLATFORMS 的 claim_fn),这里是通用路径。 local spec = { - url = plat.checkin.url, - method = plat.checkin.method or "POST", - headers = plat.checkin.headers or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }, - body = plat.checkin.body or "{}", + url = plat.checkin and plat.checkin.url or "", + method = (plat.checkin and plat.checkin.method) or "POST", + headers = (plat.checkin and plat.checkin.headers) + or { ["Authorization"] = "Bearer " .. tok, ["Content-Type"] = "application/json" }, + body = (plat.checkin and plat.checkin.body) or "{}", } if plat.claim_fn then - local custom, cerr = plat.claim_fn(plat, tok, args) + local custom, cerr = plat.claim_fn(plat, tok, credArgs) if cerr then return { status = 400, json = { error = cerr } } end if custom then spec = custom end end + -- done 哨兵:平台自己跑完了多步流程(如 codearts 的 账户→活动→claim→confirm), + -- 不再由通用路径发送单个请求。 + if type(spec) == "table" and spec.done then + local acct0 = acctEntry or plugin.state.accounts[plat.id] or {} + acct0.last_claim = os.time and os.time() or 0 + acct0.last_claim_status = spec.kind == "success" and 200 or 0 + acct0.last_claim_summary = spec.message + if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end + logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind)) + local outcome = { kind = spec.kind, message = spec.message } + -- action_required 必须透传:它是给 UI 的**显式信号**("这一步需要用户去 + -- 官方客户端操作"),不能靠文案猜。参考 qoder-credits 明确定义了这个字段。 + if spec.action_required then outcome.action_required = true end + return { status = 200, json = { ok = spec.kind == "success", outcome = outcome } } + end + local resp, err = __llmsproxy_http(spec) if err then logEvent("领取失败 " .. plat.id .. ": " .. err) @@ -2761,10 +5179,685 @@ end -- -- 声明了 schedule 的插件会得到内核 ticker 驱动(无浏览器也执行), -- 且任务跑在本插件自己的 VM 上 —— 所以任务里读 state/保险库与钩子看到的是同一份。 +-- 周期任务表。三项,各自理由见下(参考 index.ts / models.ts 的调度对照): +-- +-- auto_claim 每天 08:00 领取额度(参考 auto-checkin 的日频语义) +-- refresh_models 每 2 小时刷新远端模型目录(参考 MODEL_REFRESH_INTERVAL_MS) +-- refresh_creds 每 30 分钟检查凭据续期(参考 REFRESH_INTERVAL_MS) +-- +-- ⚠️ 「启动即跑一轮」由调度器的 first_due_now 标记保证(见 plugin_runtime.go +-- 的 firstDue):参考点名过这个缺陷 —— 短寿命凭据(cline 1 小时、codearts +-- 约 2 小时、raccoon 3 小时)在宿主停机期间早就过期,只用 interval 的话 +-- 重启后要等满一个周期才续期,期间一直显示「已过期」。 plugin.schedule = { { fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 }, + { fn = "refresh_models", every = 7200, first_due_now = true, jitter = 120 }, + { fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 }, } +-- ============================================================================ +-- ============================================================================ +-- ============================================================================ +-- ============================================================================ +-- ============================================================================ +-- 模型能力元数据(照参考 opencode-capability.ts) +-- ============================================================================ +-- 数据源:models.dev 的能力表。`/zen/v1/models` **只返回 4 个字段** +-- (id/object/created/owned_by),**不含任何能力信息** —— 能力在 models.dev。 +-- +-- 三个字段的用途(照参考): +-- modalities.input → 能力主源(['text'] / ['text','image']) +-- cost.input/output → **免费判定的权威来源**(两者同时为 0 = 免费) +-- limit.context/output → 上下文窗口与输出上限 +-- +-- ⚠️⚠️ **读取必须非阻塞**(参考的真机事故 2026-10-02): +-- 那个接口实测 **5.05 MB / 首字节 720ms / 下载 1.4s**。参考最初在模型列表渲染 +-- 路径上直接 await 它,于是模型选择器要等 1.4s+ 才能出列表 —— +-- 表现是「点开是一片空白」,用户报障「选择模型还是点击没弹出列表」。 +-- +-- ⇒ 三条硬约定(改动前先读): +-- 1. **渲染路径上只能读同步缓存,永不等待网络**; +-- 2. 磁盘缓存冷启动直接命中; +-- 3. 拉取在**后台**进行(定时任务里),完成后下次读取自然生效。 +-- +-- ⚠️ 我们额外的一条:本插件是**服务端**进程、没有"渲染路径", +-- 但 5MB 的下载仍不能放在请求路径上 —— 故挂在 refresh_models 定时任务里, +-- 与模型目录刷新同一节拍。请求路径只读缓存。 +local MODELS_DEV_URL = "https://models.dev/api.json" +local MODELS_DEV_TTL = 6 * 3600 -- 6 小时(照参考的 TTL 量级) +-- +-- ⚠️ **已知取不到,属设计约束而非故障**:该端点实测 **5.64 MB**,而内核的 +-- `__llmsproxy_http` 有 **4 MiB 响应上限**(`httpMaxResponse`,刻意的堆保护 —— +-- 不能让一个插件把整份数据拉进网关堆)。参考提到的"轻量分片端点" +-- (`/api/v1/opencode.json`)实测返回的是 **HTML 首页**(2.0 MB),不是 JSON。 +-- +-- ⇒ 能力元数据定位为**可选增强**: +-- * 拉得到 → isFree/上下文/模态用权威值; +-- * 拉不到 → 如实退回兜底表,且失败信息说明是"响应超限"(让运维知道 +-- 这是上限约束,不是网络坏了)。 +-- **不**为了它去放宽内核上限(那会让所有插件都能拉 5MB+ 进堆)。 + +-- normalizeCapability(id, raw):照参考 normalizeEntry 的字段口径。 +-- +-- ⚠️ **video / audio / pdf 一律降级为 text**:下游只认 text/image 两值, +-- 声明不存在的模态会投影出我们并不发送的内容。 +local function normalizeCapability(id, raw) + if type(raw) ~= "table" then return nil end + local declared = {} + local mods = raw.modalities + if type(mods) == "table" and type(mods.input) == "table" then + for _, m in ipairs(mods.input) do declared[#declared+1] = tostring(m) end + end + local modalities = { "text" } + for _, m in ipairs(declared) do + if m == "image" then modalities = { "text", "image" } break end + end + local lim = type(raw.limit) == "table" and raw.limit or {} + local cost = type(raw.cost) == "table" and raw.cost or {} + local ci, co = tonumber(cost.input), tonumber(cost.output) + local efforts = {} + if type(raw.efforts) == "table" then + for _, e in ipairs(raw.efforts) do efforts[#efforts+1] = tostring(e) end + end + return { + id = id, + name = type(raw.name) == "string" and raw.name ~= "" and raw.name or id, + modalities = modalities, + contextWindow = tonumber(lim.context) or 0, + maxOutputTokens = tonumber(lim.output) or 0, + -- ⚠️ `reasoning` 与 `efforts` 必须一致:只有真拿到档位才报"支持思考"。 + -- `reasoning=true` 但 `efforts` 为空会声明一个空选择器(UI 出现但无内容)。 + reasoning = (raw.reasoning == true) and #efforts > 0, + efforts = efforts, + toolCall = raw.tool_call ~= false, + isFree = (ci ~= nil and ci == 0 and co ~= nil and co == 0), + } +end + +-- capabilityOf(provider, modelId):**只读缓存**(绝不触发网络)。 +-- 缓存未就绪时返回 nil —— 调用方据此退回兜底表,而不是等一个 5MB 的下载。 +local function capabilityOf(provider, modelId) + local table_ = plugin.state.capabilities and plugin.state.capabilities[provider] + if type(table_) ~= "table" then return nil end + return table_[modelId] +end + +-- refreshCapabilities():**后台**拉 models.dev 并落缓存(定时任务调用)。 +-- 失败**不清缓存**(继续用上次的好数据)。 +local function refreshCapabilities() + local cached = plugin.state.capabilitiesMeta + local now = os.time and os.time() or 0 + if cached and cached.at and cached.at <= now and (now - cached.at) < MODELS_DEV_TTL then + return false, "未到刷新时间" + end + local resp, err = __llmsproxy_http({ + url = MODELS_DEV_URL, method = "GET", timeout_ms = 30000, + headers = { ["Accept"] = "application/json" }, + }) + if err or not resp or resp.status ~= 200 then + local es = tostring(err or (resp and resp.status)) + -- 把"响应超限"单独说清楚:那是**内核的刻意的堆保护**(4 MiB),不是网络故障。 + -- 否则运维会去查网络、查白名单,而真正的原因是我们主动不放开这个上限。 + if es:find("byte cap") then + return false, "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护," + .. "不为此放宽)—— 能力增强不可用,已退回兜底表" + end + return false, "拉取失败: " .. es + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return false, "响应无法解析" end + -- 按 provider 分表:每个平台从自己的键下取(不同平台可共用同一数据源) + local out = {} + local n = 0 + for _, plat in ipairs(allPlatforms()) do + local key = plat.capability_key or plat.id + local src = rec[key] + if type(src) == "table" then + local models = type(src.models) == "table" and src.models or src + local tbl = {} + for id, raw in pairs(models) do + local cap = normalizeCapability(tostring(id), raw) + if cap then tbl[tostring(id)] = cap n = n + 1 end + end + out[plat.id] = tbl + end + end + plugin.state.capabilities = out + plugin.state.capabilitiesMeta = { at = now, count = n } + markDirty() + return true, n +end + +-- 通用件:TTL 缓存 + 串行队列(照参考 ttl-cache.ts / serial-queue.ts) +-- ============================================================================ +-- 为什么抽出来:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"逻辑 +-- (远端目录闸门、余额 60s 缓存、登录轮询……)。手写版本各写各的, +-- 边界条件(墙钟回拨、在飞去重、失败不缓存)容易漏 —— 集中一处才好审。 +-- +-- ⚠️ Lua 无协程,做不了"共享同一个 Promise"。这里的"在飞去重"用**时间戳** +-- 表达:记录 in_flight_at,短窗口内的重复请求直接跳过。等价效果,机制不同。 +-- 这是刻意的取舍:能在单 VM 单锁里工作,且不引入任何 async 语义。 + +-- ttlCacheGet(namespace, key, ttl, loader): +-- * 命中且未过期 → 直接返回缓存值 +-- * 在飞(短窗口内有同 key 的加载)→ 返回 nil(调用方自行降级,不要重复打) +-- * 否则调 loader(),把非 nil 结果缓存 ttl 秒 +-- +-- ⚠️ **失败不缓存**:loader 返回 nil 时不写缓存(让下次能重试), +-- 但会记 in_flight_at 防止**同一瞬间**的重复打。这与"失败进冷却"是两件事 —— +-- 冷却由调用方决定(如目录闸门的 30s),本助手只负责 TTL 与去重。 +-- +-- ⚠️ 墙钟回拨防护(照参考):`now < cached_at` 时视为已过期,而不是 +-- "还没到"。回拨一天会让 `now - cached_at` 变成巨大负数 ⇒ 缓存永远命中 ⇒ +-- 上游改了也自愈不了。 +local function ttlCacheGet(namespace, key, ttl, loader) + plugin.state.ttlCache = plugin.state.ttlCache or {} + local ns = plugin.state.ttlCache[namespace] + if not ns then ns = {} plugin.state.ttlCache[namespace] = ns end + local now = os.time and os.time() or 0 + local e = ns[key] + if type(e) == "table" then + -- 墙钟回拨:cached_at 在未来 ⇒ 视为已过期(否则永久命中) + if e.at <= now and (now - e.at) < ttl then + return e.value + end + ns[key] = nil + end + -- 在飞去重:同一瞬间的重复请求不重复打(Lua 无协程,用时间戳近似) + local inflight = plugin.state.ttlInflight or {} + plugin.state.ttlInflight = inflight + local ik = namespace .. ":" .. tostring(key) + local last = inflight[ik] + if last and last <= now and (now - last) < 1 then + return nil + end + inflight[ik] = now + local ok, value = pcall(loader) + if not ok or value == nil then return nil end + ns[key] = { at = now, value = value } + return value +end + +-- ttlCacheInvalidate(namespace, key?):失效缓存(key 省略则整个 namespace)。 +-- +-- ⚠️ **必须一并清掉在飞标记**。否则:清缓存 → 同一秒内重载 → 被"在飞去重" +-- 挡住 → 返回 nil。调用方看到的是"刚清了缓存却拿不到值",而数据其实好好的 +-- (实测踩到:改窗口后立即查余额得到 nil)。失效的语义是"忘掉这次加载", +-- 在飞标记也是这次加载的一部分。 +local function ttlCacheInvalidate(namespace, key) + plugin.state.ttlCache = plugin.state.ttlCache or {} + if key == nil then + plugin.state.ttlCache[namespace] = {} + elseif plugin.state.ttlCache[namespace] then + plugin.state.ttlCache[namespace][key] = nil + end + local inflight = plugin.state.ttlInflight + if inflight then + if key == nil then + local prefix = namespace .. ":" + for k in pairs(inflight) do + if k:sub(1, #prefix) == prefix then inflight[k] = nil end + end + else + inflight[namespace .. ":" .. tostring(key)] = nil + end + end +end + +-- serialQueue(namespace):取一个串行队列的锁状态。 +-- +-- 语义(照参考 serial-queue):同一时刻只有一个任务在跑,其余**排队**。 +-- Lua 无协程 ⇒ 无法"排队等待",只能做到**互斥 + 让路**:抢不到就立刻返回 nil, +-- 由调用方跳过本次(对定时任务与轮询是正确语义:下一次会重试)。 +-- +-- ⚠️ 必须带**过期时间**:持有者崩了/超时了,锁必须能自解,否则该 namespace +-- 永久卡死(参考用 promise finally 兑现,我们只能用 TTL)。 +local function serialTryAcquire(namespace, ttl) + plugin.state.serialLock = plugin.state.serialLock or {} + local now = os.time and os.time() or 0 + local held = plugin.state.serialLock[namespace] + if held and held <= now and (now - held) < (ttl or 120) then + return false + end + plugin.state.serialLock[namespace] = now + return true +end + +local function serialRelease(namespace) + if plugin.state.serialLock then plugin.state.serialLock[namespace] = nil end +end + +-- 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts) +-- ============================================================================ +-- 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」), +-- 而**服务端扣哪个包不由我们决定**。插件能做的是决定**用哪个号**: +-- * 优先用「还有 15 天内到期积分」的号(那部分再不用就作废) +-- * 只剩永久积分的号排最后 +-- * **锁定永久积分**时,只剩永久积分的号**等同于不可用** +-- +-- 为什么是 15 天(实测两站差别极大,参考有对照表): +-- WorkBuddy 5 个号:Bonus Pack 250(8~9 天)+ Free Plan 100(3008 天) +-- → 锁定后有 4 个号仍可用,1 个(Bonus Pack 已耗尽)不可用,正是期望效果 +-- CodeBuddy 中国版 1 个号:裂变包共 10064.6,**距到期最少 17 天** +-- → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」 +-- 中国版按 30 天发包,故"距到期"天然落在 17~30 天 —— 判据本身没错,这类池 +-- 想用锁定就得把窗口放宽(如设 31)。窗口**只能由用户显式改**,默认 15。 +local EXPIRING_WINDOW_DAYS_DEFAULT = 15 + +expiringWindowDays = function() + -- 优先级:settings(面板可改)→ env(部署可改)→ 默认 15。 + -- + -- ⚠️ **不能**写成 `tonumber(raw) or 默认`:0 是合法值(表示"没有临时积分", + -- 即全部算永久)。参考在这一点上专门写了注释。 + local cfg = plugin.state.settings and plugin.state.settings.expiring_window_days + if cfg ~= nil then + local n = tonumber(cfg) + if n and n >= 0 then return n end + end + -- ⚠️ 本 VM 的 os 表**没有** setenv(实测:只有 clock/date/difftime/execute/ + -- exit/getenv/remove/rename/setlocale/time/tmpname),所以面板改值必须走 + -- settings 而不是"设环境变量"。getenv 只用于读取部署期注入的值。 + local raw = os.getenv and os.getenv("DSH_BUDDY_EXPIRING_WINDOW_DAYS") + if type(raw) == "string" and raw ~= "" then + local n = tonumber(raw) + if n and n >= 0 then return n end + end + return EXPIRING_WINDOW_DAYS_DEFAULT +end + +-- balanceTier(expiring, permanent, allow_permanent): +-- "expiring" 有 15 天内到期的积分 → 最优先(不用就作废) +-- "permanent" 只剩永久积分 → 次优先;锁定永久积分时视为不可用 +-- "none" 都没有 → 不可用 +local function balanceTier(expiring, permanent, allow_permanent) + local function pos(v) + local n = tonumber(v) + if n and n > 0 then return n end + return 0 + end + if pos(expiring) > 0 then return "expiring" end + if allow_permanent == false then return "none" end + if pos(permanent) > 0 then return "permanent" end + return "none" +end + +local function tierUsable(tier) + return tier ~= "none" +end + +-- 余额缓存(TTL 60 秒,照参考)。**只缓存原料,绝不冻结分类** —— +-- 「临时/永久」是**当前时刻的函数**(到期时间 - now 与窗口比较),而宿主长期 +-- 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就 +-- 越过了线。若把分类也缓存,选号会继续按"永久"处理一笔马上要作废的积分 +-- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。 +local BALANCE_CACHE_TTL = 60 + +-- (余额缓存改用通用 TTL 缓存后,这个助手不再需要) + +-- accountBalanceRaw(provider, accountId):取该账号的余额原料(带 TTL 缓存)。 +-- 失败**不抛错**(照参考:查询失败归入最后一档),返回 nil 让调用方降级。 +local function accountBalanceRaw(provider, accountId) + -- 用通用 TTL 缓存(照参考 ttl-cache):命中即返回、在飞去重、失败不缓存。 + -- ⚠️ 缓存的是**原料**(包列表明细),分桶每次都现算 —— 见本节的注释。 + local plat = platformById(provider) + local acct = poolGet(provider, accountId) + if not plat or not acct or not plat.balance then return nil end + local tok = credentialOf(acct.credentialRef) + if not tok or tok == "" then return nil end + local plate = plat + local act = acct + return ttlCacheGet("balance", provider .. ":" .. accountId, BALANCE_CACHE_TTL, function() + -- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移) + local rec, err = fetchAccountJSON(plate.balance, tok, + credentialFull(act.credentialRef), plate) + if err or type(rec) ~= "table" then return nil end + if plate.balance.tier_fn then + -- 平台声明了分档提取器(buddy/loomy 的包列表要按到期时间现算) + local okf, t = pcall(plate.balance.tier_fn, rec) + if okf and type(t) == "table" then return t end + return nil + end + if plate.balance.total_fn then + -- 只有总量的平台:全部算"永久"(无到期信息 ⇒ 不会凭空造出临时档) + local okf, total = pcall(plate.balance.total_fn, rec) + if okf and type(total) == "number" then + return { permanent_balance = total, expiring_balance = 0 } + end + end + return nil + end) +end + +-- rankAccounts(provider):按"积分何时作废"优先排序该平台的账号。 +-- 返回 {usable={...}, all={...}}。查询失败的账号归入最后一档(照参考)。 +local function rankAccounts(provider) + local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits + local allowPermanent = not lockPermanent + local expiring, permanent, none = {}, {}, {} + for _, a in ipairs(poolList(provider)) do + if a.enabled ~= false then + local raw = accountBalanceRaw(provider, a.id) + local tier + if raw == nil then + tier = "none" -- 查不到 → 最后(不编造) + else + tier = balanceTier(raw.expiring_balance or raw.expiringBalance, + raw.permanent_balance or raw.permanentBalance, allowPermanent) + end + if tier == "expiring" then expiring[#expiring+1] = a + elseif tier == "permanent" then permanent[#permanent+1] = a + else none[#none+1] = a end + end + end + local all = {} + for _, a in ipairs(expiring) do all[#all+1] = a end + for _, a in ipairs(permanent) do all[#all+1] = a end + for _, a in ipairs(none) do all[#all+1] = a end + -- 可用 = 临时档(永远)+ 永久档(仅未锁定时) + local usable = {} + for _, a in ipairs(expiring) do usable[#usable+1] = a end + if allowPermanent then + for _, a in ipairs(permanent) do usable[#usable+1] = a end + end + return { usable = usable, all = all, expiring = #expiring, + permanent = #permanent, none = #none, + locked = lockPermanent and true or false } +end + +-- 已失效模型(照参考 dead-model-store.ts) +-- ============================================================================ +-- 解决的真实报障:用户选中某平台的兜底模型,每轮都 404 —— +-- 该模型**已被上游下架**,但模型列表里仍显示、还标着"免费"。 +-- 根因:各平台的兜底表是**编译期快照**,上游下架时不会自己跟着变。 +-- +-- ⚠️ **不能用「远端目录里没有 ⇒ 已下架」来剔除**(参考文件头实测): +-- 那个判据只在"远端目录完整权威"的平台上成立。buddy 那类平台的远端目录 +-- **已知残缺**(CLI token 只拿得到 13 个内部别名,IDE 有 20 个), +-- 按目录比对会把"你有权用、只是 token 看不到"的模型删掉 —— 参考作者已为此 +-- 踩过一次。qoder 更是**没有**可比对的远端目录。 +-- ⇒ 唯一跨平台安全的判据是**阳性证据**:这个模型**真的**请求失败并返回 +-- 「模型不存在」时,才记为失效。**绝不从残缺目录反推。** + +-- 判定为"模型已失效"的正向模式(照参考 GONE_PATTERNS 逐条)。 +-- +-- ⚠️ `model_not_found` 必须要求它**独占一句话成分**:只加词边界不够 —— +-- 它在遥测/日志文案里常作为**字段名**出现,后面紧跟别的内容: +-- error_class=model_not_found, retry allowed → 初版误判下架 +-- model_not_found_reason=upstream → 初版误判下架 +-- 误判会把**可用模型藏 30 天**、用户须手动恢复。故后面只允许结束、句末标点 +-- 或收尾括号/引号,**不含逗号**。 +local DEAD_GONE_PATTERNS = { + "model%s+not%s+found", + "unknown%s+model", + "no%s+such%s+model", + "nonexistent%s+model", +} + +-- 反向模式:命中即**不是**下架(照参考 NOT_GONE_PATTERNS)。 +-- 逐条都有理由,不要删: +-- * rate limit / quota / insufficient → 换时段或加额度就能用 +-- * unauthor/forbidden/invalid token → 认证问题,不是模型不存在 +-- * `expired` **必须带主语**(token/session/credential/key/auth/lease), +-- 否则会误杀正常的下架文案:`model expired-v3 does not exist`、 +-- `the model lease expired`(租约到期也是下架的一种说法) +local DEAD_NOT_GONE_PATTERNS = { + "rate%s*%-?%s*limit", + "too%s+many%s+requests", + "quota", + "insufficient", + "unauthor", "forbidden", "invalid%s+token", "invalid%s+api%s*key", "invalid%s+credential", + "token%s+expired", "session%s+expired", "credential%s+expired", "key%s+expired", + "auth%s+expired", "lease%s+expired", + "expired%s+token", "expired%s+session", "expired%s+credential", "expired%s+key", + "expired%s+auth", +} + +-- isModelGone(message):把一段错误文本判定为"模型已失效"。 +-- +-- 返回 true 的**唯一**条件是出现阳性证据;拿不准一律 false —— +-- 误判的代价是把可用模型藏 30 天,漏判的代价只是用户再看到一次 404。 +local function isModelGone(message) + if type(message) ~= "string" or message == "" then return false end + local m = message:lower() + for _, pat in ipairs(DEAD_NOT_GONE_PATTERNS) do + if m:find(pat) then return false end + end + for _, pat in ipairs(DEAD_GONE_PATTERNS) do + if m:find(pat) then return true end + end + -- `model_not_found` 形态:要求后面只剩结束/句末标点/收尾括号引号。 + -- Lua 的 find 无前瞻,故手动定位后检查紧随的字符。 + local from = 1 + while true do + local a, b = m:find("model[_%-]not[_%-]found", from) + if not a then break end + local rest = m:sub(b + 1, b + 1) + -- 只允许:结束、句末标点、收尾括号/引号。**不含逗号** + -- ("…not_found, retry allowed" 里逗号后仍是另一个字段 ⇒ 不算阳性)。 + -- 字符类里 `]` 必须放最前或转义;这里直接列字符以免 Lua 解析歧义。 + -- 用**单引号**的 Lua 串,避免字符类里的双引号提前终止字符串。 + if rest == "" or rest:find('[%]%)%}%.!%;"]') then return true end + from = b + 1 + end + -- 「id + 紧邻的不存在语义」:只认 found/exist。 + -- ⚠️ 不认 available/supported —— 那是"换套餐/换地域就能恢复",不是下架。 + if m:find("does%s+not%s+exist") or m:find("doesn't%s+exist") then return true end + return false +end + +-- 失效记录 TTL(秒)。照参考默认 30 天;过期自动复活,避免永久隐藏。 +local DEAD_MODEL_TTL_DAYS = 30 + +local function deadModelsOf(provider) + plugin.state.deadModels = plugin.state.deadModels or {} + local p = plugin.state.deadModels[provider] + if not p then p = {} plugin.state.deadModels[provider] = p end + return p +end + +-- recordDeadModel:记录一次**阳性**证据(真正 404 了)。 +-- 返回 true 表示这是新记录(调用方据此决定要不要重同步源)。 +local function recordDeadModel(provider, modelId, reason) + if not modelId or modelId == "" then return false end + local p = deadModelsOf(provider) + if p[modelId] then return false end + p[modelId] = { at = os.time and os.time() or 0, reason = tostring(reason or ""):sub(1, 120) } + return true +end + +-- deadModelIds(provider):当前仍有效的失效模型 id 集合(已过 TTL 的自动清掉)。 +deadModelIds = function(provider) + local p = deadModelsOf(provider) + local now = os.time and os.time() or 0 + local ttl = DEAD_MODEL_TTL_DAYS * 86400 + local out = {} + for id, meta in pairs(p) do + local at = type(meta) == "table" and tonumber(meta.at) or 0 + if ttl > 0 and (now - at) > ttl then + p[id] = nil -- 过期复活(照参考 TTL 语义) + else + out[id] = true + end + end + return out +end + +-- clearDeadModels(provider, modelId?):清失效记录(不带 id 则清该平台全部)。 +local function clearDeadModels(provider, modelId) + local p = deadModelsOf(provider) + if modelId and modelId ~= "" then + local had = p[modelId] ~= nil + p[modelId] = nil + return had and 1 or 0 + end + local n = 0 + for _ in pairs(p) do n = n + 1 end + plugin.state.deadModels[provider] = {} + return n +end + +-- 远端目录加载闸门(照参考 remote-catalog-gate.ts) +-- ============================================================================ +-- 参考明确点名「**不能省**」,理由实测: +-- 宿主会对**每个** provider 拉目录、再对**每个模型**解析一次,于是「目录没拿到」 +-- 被放大成**一次请求 × 每个模型**。若失败时不落任何标记,下一次还会再拉一遍, +-- 而这些拉取的超时是 30–60s 量级 —— 未登录/离线时的表现就是「首屏巨长」。 +-- +-- 三道语义(照参考): +-- ① 命中缓存:有远端目录就直接用(不在本闸门内) +-- ② 并发去重:同一次加载的多个调用共享同一次 +-- ③ 失败/空结果**冷却**:避免短时间内反复打同一个坏上游 +-- +-- ⚠️ 冷却只影响**拉取时机**,不影响兜底目录:冷却期内照旧回落到静态表, +-- 界面不会因此少模型。 +-- +-- ⚠️ 本 Lua 无协程(单 VM 单锁),做不了"共享同一个 Promise"。用**时间戳 +-- 闸门**达到等价效果:记录 in_flight_at(防同轮重复)+ retry_at(失败冷却)。 +local REMOTE_CATALOG_COOLDOWN = 30 -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS + +local function catalogGateOf(provider) + plugin.state.catalogGate = plugin.state.catalogGate or {} + local g = plugin.state.catalogGate[provider] + if not g then g = {} plugin.state.catalogGate[provider] = g end + return g +end + +-- catalogGateCooling(provider):是否处于"上次没拿到目录"的冷却窗口内。 +-- +-- ⚠️ 照参考的墙钟回拨防护:`retry_at` 用墙钟算,系统时间被回拨时 +-- `now < retry_at` 会把冷却**拉长**(回拨一天就是事实上的永久卡死)。 +-- 故只认"剩余量不超过一个冷却周期"的窗口,超长的按已到期处理。 +local function catalogGateCooling(provider, now) + local g = catalogGateOf(provider) + if not g.retry_at then return false end + if now >= g.retry_at then return false end + if g.retry_at - now > REMOTE_CATALOG_COOLDOWN then return false end + return true +end + +-- catalogGateBegin(provider):尝试进入一次加载。返回 true 表示"本次由你拉"。 +local function catalogGateBegin(provider, now) + local g = catalogGateOf(provider) + if catalogGateCooling(provider, now) then return false end + -- 同一轮内的并发去重:正在飞就不再发起(参考的 inFlight) + if g.in_flight_at and (now - g.in_flight_at) < 10 then return false end + g.in_flight_at = now + return true +end + +-- catalogGateEnd(provider, got, now):收尾。拿到目录清冷却,没拿到进冷却。 +local function catalogGateEnd(provider, got, now) + local g = catalogGateOf(provider) + g.in_flight_at = nil + g.retry_at = got and nil or (now + REMOTE_CATALOG_COOLDOWN) +end + +-- refresh_models():定时把远端模型目录拉回来并重同步所有源。 +-- +-- 为什么需要它:模型目录写进了**上游源**(源的 models 决定网关能路由哪些 +-- 模型)。不刷新就只能用兜底表 —— 平台上新模型后网关永远看不到, +-- 表现为"某个新模型 404 / 不可选"。 +function plugin.refresh_models() + local updated, failed, cooled = 0, {}, 0 + local now = os.time and os.time() or 0 + for _, plat in ipairs(allPlatforms()) do + if plat.models_remote then + -- 目录闸门:冷却期内不拉(避免对坏上游反复打;照参考 remote-catalog-gate) + if not catalogGateBegin(plat.id, now) then + cooled = cooled + 1 + goto continue + end + local ids, err = refreshRemoteModels(plat.id) + catalogGateEnd(plat.id, ids ~= nil, now) + if ids then + updated = updated + 1 + -- 目录变了就重同步该平台所有源(模型清单在源里) + for _, a in ipairs(poolList(plat.id)) do + syncAccountSource(plat.id, a.id) + end + else + failed[#failed + 1] = plat.id .. ": " .. tostring(err) + end + end + ::continue:: + end + -- 冷却期内的平台不算失败(它们上次已经记过原因,重复记会淹没真正的错误) + if cooled > 0 then + logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台") + end + -- 顺带刷新模型能力元数据(照参考 opencode-capability)。 + -- ⚠️ 必须在这里(后台定时任务)而不是请求路径 —— 见该节的"非阻塞"约定。 + local cok, cres, cerr = pcall(refreshCapabilities) + if not cok then + -- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权" + -- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。 + logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120)) + elseif cres == true then + logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)") + elseif cerr then + logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120)) + end + if updated > 0 then + logEvent("模型目录已刷新(" .. updated .. " 个平台)") + end + -- 失败留痕但**不清缓存**(refreshRemoteModels 内部已保证): + -- 拉不到时继续用上次的好数据,绝不能把目录清空。 + if #failed > 0 then + logEvent("模型刷新失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 200)) + end +end + +-- refresh_creds():定时检查并续期凭据。 +-- +-- ⚠️ **不看 enabled**(照参考的既有铁律):停用只影响自动选号,不该让凭据 +-- 停止续期。否则"所有账号都被停用"时续期根本不跑,凭据一路烂到 +-- refresh_token 失效、只能重新登录。 +-- +-- ⚠️ **只对「距过期不足 lead 时间」的账号发请求**(照参考 expiry-sync 的 +-- shouldRefreshNow):每次无脑续期会在启动时打出几十个无谓请求。 +-- 没有过期时间的账号只做本地对账,不发请求。 +function plugin.refresh_creds() + local lead = 3600 -- 提前 1 小时续(照参考的 lead 语义) + local now = os.time and os.time() or 0 + local refreshed, skipped, failed = 0, 0, {} + for _, plat in ipairs(allPlatforms()) do + if plat.exchange_body or plat.refresh_url then + for _, a in ipairs(poolList(plat.id)) do + local cred = credentialFull(a.credentialRef) + if type(cred) == "table" then + local exp = tonumber(a.expiresAt) or credentialExpiresAtMs(cred) + local due = exp == nil or exp == 0 + if not due then + -- expiresAt 单位可能是毫秒;统一成秒再比 + local e = exp > 1e11 and math.floor(exp / 1000) or exp + due = (e - now) <= lead + end + if due then + local ok, err = plugin.account_refresh({ platform = plat.id, account_id = a.id }) + -- ⚠️ 判据用 `refreshed`,**不是** `ok`:account_refresh 在"平台未 + -- 声明续期方式"时也会回 ok:true(源照旧同步),用 ok 计数就会把 + -- "没续"算成"续了" —— 这正是此前日志说谎的机制。 + if ok and ok.json and ok.json.refreshed then refreshed = refreshed + 1 + else + skipped = skipped + 1 + local why = ok and ok.json and ok.json.message or err + if why and why ~= "该平台未声明续期方式" then + failed[#failed+1] = plat.id .. ": " .. tostring(why) + end + end + else + skipped = skipped + 1 + end + end + end + end + end + if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个") end + if #failed > 0 then + logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300)) + end +end + function plugin.auto_claim() -- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。 plugin.state.settings = plugin.state.settings or {} @@ -2772,6 +5865,11 @@ function plugin.auto_claim() for _, plat in ipairs(allPlatforms()) do local cap = CHANNEL_CREDITS[plat.id] if not cap or not cap.claim then goto continue end + -- auto_exclude:领取有**代价**的平台(zcode 每次领取现场产阿里云 captcha, + -- headful Chromium + 按设备限流 150 次/小时)。代价发生在**调用期间**, + -- "等它报错再判定"来不及,故必须在调之前排除(照参考 + -- isAutoCheckinExcluded)。用户仍可手动领取。 + if cap.auto_exclude then goto continue end local legacy = accountOf(plat.id) if legacy and legacy.enabled then plugin.claim_daily({ platform = plat.id }) @@ -2822,6 +5920,8 @@ plugin.http = { roles = { "admin" }, description = "调账号优先级" }, { path = "channels/model/list", method = "POST", handler = "model_list", roles = { "admin" }, description = "列平台模型与开关态" }, + { path = "channels/model/refresh", method = "POST", handler = "model_refresh", + roles = { "admin" }, description = "从上游拉取模型目录(远端为主、兜底补位)" }, { path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled", roles = { "admin" }, description = "开关单个模型" }, { path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled", @@ -2846,6 +5946,8 @@ plugin.http = { roles = { "admin" }, description = "永久积分锁定" }, { path = "channels/account/reset", method = "POST", handler = "account_reset", roles = { "admin" }, description = "清账号限流标记" }, + { path = "channels/account/adopt", method = "POST", handler = "account_adopt", + help = "把旧版非池凭据接管进账号池(升级后账号消失时用)" }, { path = "channels/account/retest", method = "POST", handler = "account_retest", roles = { "admin" }, description = "重测账号" }, { path = "channels/model/set_disabled_many", method = "POST", handler = "model_set_disabled_many", @@ -2887,8 +5989,8 @@ plugin.http = { -- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、 -- base_url)。两个用途: --- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求) --- 2. 测试把端点指到假上游,行为测试才能不打真厂商 +-- 1. 运维把某渠道指向自建代理/镜像(厂商被墙、企业出口、审计要求) +-- 2. 测试把端点指到假上游,行为测试才能不打真厂商 -- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段 -- 会破坏后续逻辑对形状的假设。 -- ============================================================================ @@ -2979,12 +6081,35 @@ function plugin.account_delete(req) dropAccountSource(plat.id, acct.id) __llmsproxy_secrets.delete(acct.credentialRef) poolRemove(plat.id, acct.id) + -- ⚠️ 删完最后一个账号时,必须**清掉旧版单账号源** `channels-`。 + -- + -- 真实缺陷(本次实测抓到):旧代码建的 `channels-` 源的 base_url 是 + -- **旧口径**(buddy 缺 `/v2/chat/completions` 前缀)。它不随账号删除而消失, + -- 于是以同名模型对外服务,但打的是**错的 URL** —— + -- 症状是推理返回**空内容**(HTTP 200 但没有正文),而列表里模型看着正常。 + -- 我在这上面绕了很久(一会儿 'OK' 一会儿 '',因为内核在多个源之间轮转)。 + -- ⚠️ 只在池**已空**时清:还有账号时不该动它(它可能是运维手工配的兼容源)。 + if #poolList(plat.id) == 0 then + __llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id }) + end logEvent("账号删除 " .. plat.id .. "/" .. acct.id) return { status = 200, json = { ok = true } } end -- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。 -- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。 +-- account_test:**真打一次上游**探测该账号是否可用(对齐参考 account-probe 的 +-- 「重测」语义)。 +-- +-- ⚠️ 语义修正(真实缺陷):此前这个函数**一个网络请求都不发** —— 它只做 +-- syncAccountSource 然后查内核源列表里有没有那条记录,就返回 `{ok=true, ms=1}`。 +-- 于是"测试账号"永远绿,包括凭据已失效、被限流、上游宕机的情况 —— +-- 正是「看着能用、一用就错」的那类假阳性。 +-- +-- 照参考的两点关键设计: +-- 1. **只针对指定账号、不产生副作用**:不能顺手把限流标记写到别的账号上。 +-- 2. **停用账号也要能测**:用户明确要求(凭据解析不检查 enabled)。 +-- 我们额外可选地按声明打一次真实请求(余额端点最轻,退化用 checkin_status)。 function plugin.account_test(req) local args = bodyOf(req) if args.account_id == nil then args = req end @@ -2993,16 +6118,101 @@ function plugin.account_test(req) local acct = poolGet(plat.id, args and args.account_id) if not acct then return { status = 400, json = { error = "账号不存在" } } end local tok = credentialOf(acct.credentialRef) - if not tok or tok == "" then return { status = 200, json = { ok = false, error = "无凭据" } } end - -- 同步一次源(确保最新凭据已入),返回同步结果 - syncAccountSource(plat.id, acct.id) - local srcs, _ = __llmsproxy_kernel.call("list_sources", {}) - local found = false - for _, s in ipairs(srcs or {}) do - if s.name == accountSourceName(plat.id, acct.id) then found = true break end + if not tok or tok == "" then + return { status = 200, json = { ok = false, error = "无凭据(需先登录)", + probed = false } } end - if not found then return { status = 200, json = { ok = false, error = "源未同步(上游可能不可达)" } } end - return { status = 200, json = { ok = true, ms = 1 } } + -- 同步一次源(确保最新凭据已入)—— 这是本地动作,不是"测试" + syncAccountSource(plat.id, acct.id) + + -- 真正的一次上游请求:优先余额端点(最轻、所有有余额的平台都有), + -- 退化到 checkin_status。两者都没有的平台如实报"无探测端点", + -- 不假装成功。 + local spec = plat.balance or plat.checkin_status + if not spec or not spec.url then + local srcs, _ = __llmsproxy_kernel.call("list_sources", {}) + local found = false + for _, s in ipairs(srcs or {}) do + if s.name == accountSourceName(plat.id, acct.id) then found = true break end + end + return { status = 200, json = { ok = found, probed = false, + error = found and nil or "源未同步", + note = "该平台未声明可探测端点,仅校验本地凭据与源" } } + end + local t0 = os.clock and os.clock() or 0 + local rec, err = fetchAccountJSON(spec, tok, credentialFull(acct.credentialRef), plat) + local ms = math.floor(((os.clock and os.clock() or 0) - t0) * 1000) + if err then + -- 失败要区分"凭据失效"(需重新登录)与"暂时性失败"(可重试)—— + -- 只说"失败"用户不知道该做什么。 + local kind = "transient" + local el = tostring(err):lower() + if el:find("401") or el:find("403") or el:find("unauthor") + or el:find("invalid_grant") or el:find("expired") then + kind = "auth" + end + return { status = 200, json = { ok = false, probed = true, ms = ms, + error = tostring(err), kind = kind, + action = kind == "auth" and "凭据已失效,请重新登录该账号" or "上游暂时不可用,可稍后重试" } } + end + return { status = 200, json = { ok = true, probed = true, ms = ms } } +end + +-- account_adopt:把**非池**的旧凭据(`channels:`)接管进某个池账号。 +-- +-- 为什么需要它(真实场景):v2.0.x 之前登录只写一条 `channels:`(单账号 +-- 模式),后来引入账号池后凭据落在 `channels::`。升级后老凭据仍在 +-- 保险库里、但池里没有账号引用它 —— 表现是"账号消失了"(数据其实还在)。 +-- 重新登录当然可以,但那是**用户可见的额外代价**(要再走一次授权)。 +-- +-- ⚠️ 正确做法是让**插件自己读保险库**(`__llmsproxy_secrets.get`)再写进池引用。 +-- **绝不能**把保险库文件里的密文当明文喂给 API —— 保险库读取时会解密,那样会 +-- 把凭据**二次加密**(实测踩过:1971 → 2965 字符,该账号推理开始返回空)。 +function plugin.account_adopt(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local legacyRef = "channels:" .. plat.id + -- 全程只用插件的保险库 API:读出的是**明文**(保险库负责解密) + local raw = __llmsproxy_secrets.get(legacyRef) + if not raw or raw == "" then + return { status = 200, json = { ok = false, + error = "没有可接管的旧凭据(" .. legacyRef .. ")" } } + end + local aid = args.account_id + if not aid or aid == "" then + return { status = 400, json = { error = "缺 account_id(要接管进哪个池账号)" } } + end + local acct = poolGet(plat.id, aid) + if not acct then + -- 允许直接建:占位 + 写入 + local now = os.time and os.time() or 0 + acct = { id = aid, provider = plat.id, nickname = args.nickname or aid, + enabled = true, credentialRef = "channels:" .. plat.id .. ":" .. aid, + refreshable = false, createdAt = now, last_claim = 0, last_claim_status = 0, + last_claim_summary = "" } + poolAdd(plat.id, acct) + end + -- 写入的是**明文**(raw)—— 保险库写入时自己加密。这正是要避免"手填密文"的原因。 + local _, serr = __llmsproxy_secrets.set(acct.credentialRef, raw) + if serr then return { status = 200, json = { ok = false, error = serr } } end + -- 从凭据补齐展示字段 + if type(raw) == "string" and raw:sub(1, 1) == "{" then + local dok, cred = pcall(json.decode, raw) + if dok and type(cred) == "table" then + acct.nickname = args.nickname or cred.nickname or cred.user_name or cred.user_id or acct.nickname + acct.refreshable = credentialIsRefreshable(cred) + local exp = credentialExpiresAtMs(cred) + if exp then acct.expiresAt = tostring(exp) end + end + end + poolAdd(plat.id, acct) + markDirty() + syncAccountSource(plat.id, aid) + logEvent("已接管旧凭据到 " .. plat.id .. "/" .. aid) + return { status = 200, json = { ok = true, account_id = aid, + credentialRef = acct.credentialRef, message = "旧凭据已接入账号池" } } end -- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额 @@ -3013,9 +6223,19 @@ function plugin.account_refresh(req) if not plat then return { status = 400, json = { error = "未知平台" } } end local acct = poolGet(plat.id, args and args.account_id) if not acct then return { status = 400, json = { error = "账号不存在" } } end - local tok = credentialOf(acct.credentialRef) - if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end - return { status = 200, json = { ok = true } } + -- ★ 真续期:此前这里只重同步源、一个换 token 的请求都不发却恒返回 ok:true, + -- 于是 `refresh_creds` 打出「凭据已续期 N 个」而什么都没续 —— 日志在说谎。 + local rok, rmsg = refreshCredential(plat, acct) + if not rok then + -- 失败必须**如实回错**(供 UI 显示与日志诊断)。但仍重同步源: + -- 旧 token 可能还没真过期,不该因为续期失败就把源撤掉。 + local tok = credentialOf(acct.credentialRef) + if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end + return { status = 200, json = { ok = false, refreshed = false, message = rmsg } } + end + -- 续期成功 → 用**新 token** 重建源(旧源的 Authorization 头已失效) + syncAccountSource(plat.id, acct.id) + return { status = 200, json = { ok = true, refreshed = true, message = rmsg } } end -- account_reorder:调账号优先级(对齐 account.reorder) @@ -3036,17 +6256,85 @@ end -- ============================================================================ -- 模型开关 RPC(对齐参考 model.*) -- ============================================================================ +-- plugin.model_refresh(platform?):从上游拉模型目录。 +-- +-- 不传 platform 就刷新所有**声明了 models_remote** 的平台。失败不清缓存 +-- (宁可留旧目录也别让平台变空),逐个返回结果供 UI 提示。 +function plugin.model_refresh(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local targets = {} + if args and args.platform then + local plat = platformById(args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + targets = { plat } + else + for _, p in ipairs(allPlatforms()) do + if p.models_remote then targets[#targets+1] = p end + end + end + local results = {} + for _, plat in ipairs(targets) do + local ids, err = refreshRemoteModels(plat.id) + results[#results+1] = { + platform = plat.id, + ok = ids ~= nil, + count = ids and #ids or 0, + error = err, + } + -- 目录变了就重同步该平台所有源(模型清单写进了上游源) + if ids then + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + end + end + return { status = 200, json = { ok = true, results = results } } +end + function plugin.model_list(req) local args = bodyOf(req) if args.platform == nil then args = req end local plat = platformById(args and args.platform) if not plat then return { status = 400, json = { error = "未知平台" } } end local disabled = disabledModelsOf(plat.id) + -- models_hidden:内部子 agent(照参考 isHidden 语义)不进用户可选列表。 + local hidden = {} + for _, m in ipairs(plat.models_hidden or {}) do hidden[m] = true end + -- models_free:参考的 isFree 字段(官方语义:匿名/免费可用)。 + local free = {} + for _, m in ipairs(plat.models_free or {}) do free[m] = true end local out = {} - for i, m in ipairs(plat.models or {}) do - out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false, isFree = false } + for i, m in ipairs(effectiveModels(plat.id)) do + if not hidden[m] then + -- 能力元数据优先(照参考 opencode-capability): + -- `isFree` 的**权威来源**是 models.dev 的 cost(input 与 output 同时为 0), + -- 不是我们硬编码的快照 —— 快照会随上游调整免费策略而过期。 + -- 缓存未就绪时退回声明的 models_free,绝不触发网络(那是 5MB 的下载)。 + local cap = capabilityOf(plat.id, m) + local is_free = free[m] and true or false + if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false end + local entry = { id = m, name = (cap and cap.name) or m, + disabled = disabled[m] and true or false, + isFree = is_free } + if cap then + entry.capabilities = { + modalities = cap.modalities, + contextWindow = cap.contextWindow, + maxOutputTokens = cap.maxOutputTokens, + reasoning = cap.reasoning, + efforts = cap.efforts, + toolCall = cap.toolCall, + } + end + out[#out+1] = entry + end end - return { status = 200, json = { ok = true, models = out } } + local remote = plugin.state.remoteModels and plugin.state.remoteModels[plat.id] + return { status = 200, json = { + ok = true, models = out, + remote = remote ~= nil, + fetched_at = remote and remote.fetched_at or nil, + fallbackOnly = remote == nil, + } } end function plugin.model_set_disabled(req) @@ -3070,7 +6358,7 @@ function plugin.model_set_all_disabled(req) if not plat then return { status = 400, json = { error = "未知平台" } } end local disabled = disabledModelsOf(plat.id) if args.disabled then - for _, m in ipairs(plat.models or {}) do disabled[m] = true end + for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end else for k in pairs(disabled) do disabled[k] = nil end end @@ -3090,13 +6378,14 @@ function plugin.provider_status(req) for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end local disabled = disabledModelsOf(p.id) local disabledN = 0 - for _, m in ipairs(p.models or {}) do if disabled[m] then disabledN = disabledN + 1 end end - local closed = (p.models and #p.models > 0) and disabledN == #p.models + local eff = effectiveModels(p.id) + for _, m in ipairs(eff) do if disabled[m] then disabledN = disabledN + 1 end end + local closed = #eff > 0 and disabledN == #eff local state_accounts = plugin.state.accounts[p.id] local legacyN = (state_accounts and credentialOf("channels:" .. p.id)) and 1 or 0 out[#out+1] = { id = p.id, name = p.name, flow = p.flow, closed = closed, - models = { total = #(p.models or {}), disabled = disabledN }, + models = { total = #eff, disabled = disabledN }, accounts = { total = #accts + legacyN, enabled = enabledN + legacyN }, } end @@ -3121,12 +6410,13 @@ function plugin.provider_set_enabled(req) if legacy and legacyTok then __llmsproxy_kernel.call("add_source", { source = { name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai", - api_key = legacyTok, max_concurrent = 2, - models = (function() local ms = {} for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), + api_key = legacyTok, max_concurrent = 1, + models = (function() local ms = {} local eff = effectiveModels(plat.id) + for i, m in ipairs(eff) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), } }) end else - for _, m in ipairs(plat.models or {}) do disabled[m] = true end + for _, m in ipairs(effectiveModels(plat.id)) do disabled[m] = true end for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end __llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id }) end @@ -3249,7 +6539,7 @@ end -- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances) -- ============================================================================ -- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。 -local function fetchAccountJSON(spec, tok, credTable, plat) +fetchAccountJSON = function(spec, tok, credTable, plat) local headers -- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。 -- buddy 在平台层声明一次,签到与余额复用同一套身份头。 @@ -3269,6 +6559,24 @@ local function fetchAccountJSON(spec, tok, credTable, plat) if resp.status ~= 200 then return nil, "HTTP " .. tostring(resp.status) end local ok, rec = pcall(json.decode, resp.body or "") if not ok or type(rec) ~= "table" then return nil, "响应无法解析" end + -- ⚠️ 业务级错误必须在这里也判掉:多家上游**HTTP 200 但 body 里带 error** + -- (实测 buddy 的 invalid_grant 就是这个形态)。只看 HTTP 状态码的话, + -- "凭据已失效"会被当成成功 —— 账号测试于是永远报绿,这是最误导人的假阳性。 + local e = rec.error + if type(e) == "string" and e ~= "" then return nil, e end + if type(e) == "table" then + local msg = e.message or e.msg + if type(msg) == "string" and msg ~= "" then return nil, msg end + return nil, "上游返回 error 对象" + end + -- 有些上游把错误放在 code/msg 里(code 非 0 即失败) + local code = rec.code + if code ~= nil then + local n = tonumber(code) + if n and n ~= 0 and n ~= 200 then + return nil, tostring(rec.msg or rec.message or ("业务码 " .. tostring(code))) + end + end return rec, nil end @@ -3410,21 +6718,22 @@ function plugin.model_set_disabled_many(req) end -- model.clearDead:清死模型(对齐参考 model.clearDead:从黑名单移除已下线的 id) +-- model_clear_dead:清掉"已失效模型"的记录(照参考 clearDeadModels 语义)。 +-- +-- ⚠️ 语义修正:此前这里删的是"被禁用且不在目录里"的条目 —— 那既不是失效记录, +-- 判据也用错了(拿目录反推)。参考的 clear 是:把**有阳性 404 证据**的记录清掉, +-- 让模型重新出现在列表里(用户确认上游又上了/或误判时用)。 function plugin.model_clear_dead(req) local args = bodyOf(req) if args.platform == nil then args = req end local plat = platformById(args and args.platform) if not plat then return { status = 400, json = { error = "未知平台" } } end - local disabled = disabledModelsOf(plat.id) - local removed = {} - for mid in pairs(disabled) do - local alive = false - for _, m in ipairs(plat.models or {}) do if m == mid then alive = true break end end - if not alive then disabled[mid] = nil removed[#removed+1] = mid end - end + local mid = args and args.model_id + local n = clearDeadModels(plat.id, mid) markDirty() for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end - return { status = 200, json = { ok = true, removed = removed } } + return { status = 200, json = { ok = true, removed = n, + message = n > 0 and ("已恢复 " .. n .. " 个模型") or "无失效模型记录" } } end -- 供应商排序(对齐参考 provider.getOrder/setOrder) @@ -3455,9 +6764,9 @@ function plugin.gateway_get_enabled(req) local any = false for _, p in ipairs(allPlatforms()) do local disabled = disabledModelsOf(p.id) - local total = #(p.models or {}) + local total = #effectiveModels(p.id) local allOff = total > 0 - for _, m in ipairs(p.models or {}) do if not disabled[m] then allOff = false break end end + for _, m in ipairs(effectiveModels(p.id)) do if not disabled[m] then allOff = false break end end if not allOff then any = true break end end return { status = 200, json = { ok = true, enabled = any, @@ -3615,6 +6924,50 @@ function plugin.set_endpoints(req) for k in pairs(allowed) do if args[k] then plat[k] = tostring(args[k]) changed[#changed+1] = k end end + if type(args.callback) == "table" and plat.callback then + for _, k in ipairs({ "mode", "token_url" }) do + if args.callback[k] then + plat.callback[k] = tostring(args.callback[k]) + changed[#changed+1] = "callback." .. k + end + end + end + -- client_version:有道 exchange/续期/签到的必填版本号来源。测试需把它指到假上游; + -- 同时清掉已缓存值(否则上一次测试的值会跨用例泄漏)。 + if type(args.qoder_sash_base) == "table" and args.qoder_sash_base.url then + QODER_SASH_BASE = tostring(args.qoder_sash_base.url) + changed[#changed+1] = "qoder_sash_base.url" + end + if type(args.lobster_activity_base) == "table" and args.lobster_activity_base.url then + LOBSTERAI_ACTIVITY_BASE = tostring(args.lobster_activity_base.url) + changed[#changed+1] = "lobster_activity_base.url" + end + -- codearts_sts:CodeArts 的华为 STS token 端点(PKCE 换 AK/SK 用)。 + -- 测试把它指到假上游,才能端到端验证 code 交换而不打真华为云。 + if type(args.codearts_sts) == "table" and args.codearts_sts.url then + CODEARTS_STS_TOKEN = tostring(args.codearts_sts.url) + changed[#changed+1] = "codearts_sts.url" + end + if type(args.client_version) == "table" and plat.client_version then + for _, k in ipairs({ "url", "path", "fallback" }) do + if args.client_version[k] then + plat.client_version[k] = tostring(args.client_version[k]) + changed[#changed+1] = "client_version." .. k + end + end + plugin.state.clientVersion = nil + end + if type(args.models_remote) == "table" and plat.models_remote then + -- 测试台/运维把远端目录端点重定向(键形如 endpoint0/endpoint1 对应该平台的 + -- 第 N 个端点)。生产上不该改,但没有它就无法在无网环境下断言解析口径。 + for i, ep in ipairs(plat.models_remote.endpoints or {}) do + local v = args.models_remote["endpoint" .. (i - 1)] + if v then + ep.url = tostring(v) + changed[#changed+1] = "models_remote.endpoint" .. (i - 1) + end + end + end if type(args.qr) == "table" and plat.qr then for _, k in ipairs({ "api_base", "auth_prefix", "poll_path", "login_page", "app_name" }) do if args.qr[k] then @@ -3748,8 +7101,8 @@ end -- -- 为什么需要它:VNC 只把浏览器呈现给人,登录完成后必须把结果**取出来**才能 -- 变成上游源。取法分两种: --- ① 平台声明了 collect_expr —— 在该页上求值该 JS(返回 token 字符串或对象) --- ② 未声明 —— 回退到通用提取:localStorage / sessionStorage / cookie 全量 +-- ① 平台声明了 collect_expr —— 在该页上求值该 JS(返回 token 字符串或对象) +-- ② 未声明 —— 回退到通用提取:localStorage / sessionStorage / cookie 全量 -- 查到什么存什么,由调用方/运维判断哪个是凭据(不猜语义,不编造)。 -- 取到的凭据经 complete_login 走正常入库路径(含 account_id → 池路径)。 function plugin.collect_credentials(req) @@ -3821,6 +7174,7 @@ end function plugin.stop_browser(req) local had = browserStack().session ~= nil reapBrowser() + browserStack().owner = nil if had then logEvent("服务端浏览器已回收") end return { status = 200, json = { ok = true, stopped = had } } end @@ -3886,9 +7240,9 @@ end -- 这个网关的登录流程。 -- -- callback 模式(platform.callback.mode)决定如何从回调 query 解出凭据: --- nil/"code" 标准 OAuth:直接把 code 当凭据交给 complete_login。 --- "trae" 字节 TRAE 的 token 直传形态,解析逐字对齐参考实现 --- parseTraeCallbackDetailed + exchangeTraeCallback(见 traeCallback)。 +-- nil/"code" 标准 OAuth:直接把 code 当凭据交给 complete_login。 +-- "trae" 字节 TRAE 的 token 直传形态,解析逐字对齐参考实现 +-- parseTraeCallbackDetailed + exchangeTraeCallback(见 traeCallback)。 function plugin.oauth_callback(req) local nonce = req.query and req.query.state if not nonce or nonce == "" then @@ -3905,13 +7259,19 @@ function plugin.oauth_callback(req) if s.flow ~= "callback" then return { status = 400, body = "session is not a callback flow" } end + if s.state ~= "pending" then + return { status = 409, body = "login session already consumed" } + end local plat = platformById(s.platform) local cb = plat and plat.callback or {} local mode = cb.mode or "code" local credential, account, cerr if mode == "trae" then - credential, account, cerr = traeCallback(plat, req.query) + credential, account, cerr = traeCallback(plat, req.query, s) + elseif mode == "code" and s.platform == "lobsterai" then + -- 与 poll_login 的 loopback 路径共用同一个交换助手,避免两条路漂移。 + credential, account, cerr = lobsteraiExchange(plat, req.query.authCode or req.query.code, s) elseif mode == "code" and cb.token_url then -- 标准 OAuth code → token 交换(Gemini / LobsterAI)。凭据是交换回的 access_token。 local code = req.query.code @@ -3921,10 +7281,24 @@ function plugin.oauth_callback(req) local body = (cb.token_body or "") :gsub("{code}", code) :gsub("{verifier}", s.device_verifier or "") + -- {now}/{uuid}:LobsterAI 的交换体要求这两个字段(照参考 + -- exchangeLobsteraiAuthCode:latestKeyfrom 取当前时刻、uuid 来自会话)。 + :gsub("{now}", tostring(os.time and os.time() * 1000 or 0)) + :gsub("{uuid}", s.nonce or "") + -- {port}:Gemini 的 redirect_uri 参与 token 交换校验,必须与授权时一致 + :gsub("{port}", tostring(gatewayPort())) + -- Content-Type 按请求体形态选:LobsterAI 发 JSON,Gemini 发表单。 + local ctype = "application/x-www-form-urlencoded" + if body:sub(1, 1) == "{" then ctype = "application/json" end + local hdrs = { ["Content-Type"] = ctype, ["Accept"] = "application/json" } + -- 平台的匿名头(LobsterAI 的交换端点虽不需 Authorization,但要 UA) + if type(plat.callback_headers) == "table" then + for k, v in pairs(plat.callback_headers) do hdrs[k] = v end + end local resp, err = __llmsproxy_http({ url = cb.token_url, method = "POST", - headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Accept"] = "application/json" }, + headers = hdrs, body = body, }) if err then @@ -3967,14 +7341,20 @@ function plugin.oauth_callback(req) end -- traeCallback 解析 TRAE 的 token 直传回调并(必要时)走 ExchangeToken。 --- 返回 (credential, account, nil) 或 (nil, nil, err)。 +-- 返回 (credential, account, err)。 +-- +-- credential 是 **JSON**(不是裸 access token):device_id 必须随凭据保存, +-- 因为签到要发 `X-Device-Id`,且该值限定为「每账号互不相同」(同一天两账号 +-- 共用会被「该设备已签到」拦,业务码 9074 的限流范围是 device_id 而非账号)。 +-- 参考 TraeCredential 即含 deviceId/machineId。凭据的取用端 credentialOf 会 +-- 从中取 access_token,故包成 JSON 不影响上游源。 -- -- 行为逐行对齐参考实现 trae-oauth.ts: --- refreshToken(query 或 userJwt.RefreshToken 回退)存在 → ExchangeToken 换新 --- access(并轮换 refreshToken);凭据 = 新 access。 --- 无 refreshToken 但有 userJwt.Token → 直接用它兜底(不走 ExchangeToken)。 --- 二者皆无、但有 code/authCodeInfo → **PKCE 流**,明确报"暂不支持"而非含糊失败。 --- 都没有 → 回调无效。 +-- refreshToken(query 或 userJwt.RefreshToken 回退)存在 → ExchangeToken 换新 +-- access(并轮换 refreshToken);凭据 = 新 access。 +-- 无 refreshToken 但有 userJwt.Token → 直接用它兜底(不走 ExchangeToken)。 +-- 二者皆无、但有 code/authCodeInfo → **PKCE 流**,明确报"暂不支持"而非含糊失败。 +-- 都没有 → 回调无效。 local function jsonField(raw, key) if not raw or raw == "" then return "" end local ok, v = pcall(json.decode, raw) @@ -3982,7 +7362,7 @@ local function jsonField(raw, key) return v[key] or "" end -function traeCallback(plat, q) +traeCallback = function(plat, q, s) q = q or {} local userJwt = q.userJwt or "" local refreshToken = q.refreshToken @@ -3990,7 +7370,13 @@ function traeCallback(plat, q) refreshToken = jsonField(userJwt, "RefreshToken") end local jwtToken = jsonField(userJwt, "Token") - local account = jsonField(q.userInfo or "", "ScreenName") + -- uid 与昵称都必须从 userInfo 取:uid 是签到设备身份的**派生种子** + -- (参考 trae-oauth.ts:回调字段名是 UserID,不是 user_id); + -- 昵称需经乱码修复(中文实测会变成 Óû§8847309959 这类形态)。 + local userInfo = q.userInfo or "" + local uid = jsonField(userInfo, "UserID") + local enterpriseId = jsonField(userInfo, "TenantID") + local account = traeFixNickname(jsonField(userInfo, "ScreenName"), uid) if account == "" then account = "trae" end -- PKCE 形态探测:仅在 refreshToken / jwtToken 都空时才判,与参考一致。 @@ -4003,6 +7389,18 @@ function traeCallback(plat, q) return nil, nil, "回调未携带 refreshToken / userJwt.Token / code" end + -- 凭据包成 JSON:device_id/machine_id 必须留存供签到用(见函数注释)。 + local function packTrae(access) + local cred = { access_token = access, uid = uid, enterprise_id = enterpriseId, + nickname = account } + if s then + cred.device_id = s.device_id + cred.machine_id = s.machine_id + cred.login_trace_id = s.login_trace_id + end + return json.encode(cred) + end + -- 分支 1:有 refreshToken → ExchangeToken 换新 access(轮换 refresh) if refreshToken and refreshToken ~= "" and plat.callback.exchange_url then local body = (plat.callback.exchange_body @@ -4026,17 +7424,1171 @@ function traeCallback(plat, q) if not access or access == "" then return nil, nil, "TRAE ExchangeToken 响应缺少 Token" end - return access, account, nil + return packTrae(access), account, nil end -- 分支 2:无 refreshToken,用 userJwt.Token 兜底 - return jwtToken, account, nil + if jwtToken and jwtToken ~= "" then return packTrae(jwtToken), account, nil end + return nil, nil, "回调未携带可用的 token" +end + +-- traeDeriveStream(seed, salt, nbytes):SHA-256 确定性伪随机流(返回 hex 串)。 +-- +-- 逐行对齐参考 seededStream:`SHA256(utf8(salt:seed) ++ counterBE32)` 串联, +-- counter 从 0 递增,取前 nbytes 字节。同样的 (seed,salt) 永远得到同样的字节。 +-- 内核只给 sha256_hex(字符串),没有二进制原语,故自己拼大端 counter: +-- 把计数器按 4 字节大端拆成字符,先 tohex 再转义成逐字节的字面量。 +local function traeDeriveStream(seed, salt, nbytes) + local prefix = tostring(salt) .. ":" .. tostring(seed) + local out, counter = "", 0 + while #out < nbytes * 2 do + -- counter 是大端 4 字节;counter=0 时就是 4 个 NUL。 + -- 必须用 string.char 造**真字节**:内核的 sha256_hex 经 GoStringN 按长度取串, + -- NUL 会原样保留。曾经试过用 `\\ddd` 转义串,那是错的 —— Lua 不会对 + -- 运行时拼接的字符串重新词法分析,于是哈希到的是「反斜杠+数字」文本。 + local be = string.char( + math.floor(counter / 16777216) % 256, math.floor(counter / 65536) % 256, + math.floor(counter / 256) % 256, counter % 256) + out = out .. sha256_hex(prefix .. be) + counter = counter + 1 + end + return out:sub(1, nbytes * 2) +end + +-- traeDeriveDeviceId15 / traeDeriveMarketUserId / traeDeriveSessionId: +-- 基于 user_id 的确定性设备身份。参考注释点明:每个账号天然不同设备, +-- 所以签到**不再需要**在遇到 9074 时换设备号重试(早期做法已废弃)。 +traeDeriveDeviceId15 = function(userId) + local hex = traeDeriveStream(userId, "devid", 15) + local out = {} + for i = 1, 15 do + -- 按字节取模 10:两 hex 字符 = 1 字节 + local b = tonumber(hex:sub((i - 1) * 2 + 1, i * 2), 16) + out[#out + 1] = tostring(b % 10) + end + return table.concat(out) +end + +traeDeriveMarketUserId = function(userId) + local h = traeDeriveStream(userId, "market", 16) + local bs = {} + for i = 1, 16 do bs[i] = tonumber(h:sub((i - 1) * 2 + 1, i * 2), 16) end + bs[7] = (bs[7] % 16) + 64 -- version 4(bs 下标从 1 起,故是第 7 个) + bs[9] = (bs[9] % 64) + 128 -- variant RFC 4122 + local hx = {} + for i = 1, 16 do hx[i] = ("%02x"):format(bs[i]) end + local s = table.concat(hx) + return s:sub(1, 8) .. "-" .. s:sub(9, 12) .. "-" .. s:sub(13, 16) + .. "-" .. s:sub(17, 20) .. "-" .. s:sub(21, 32) +end + +traeDeriveSessionId = function(userId) + return traeDeriveStream(userId, "sess", 32) +end + +-- utf8Encode(cp):码点 → UTF-8 字节串(Lua 5.1 无 utf8.char)。 +local function utf8Encode(cp) + if cp < 0x80 then return string.char(cp) end + if cp < 0x800 then + return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64) + end + if cp < 0x10000 then + return string.char(0xE0 + math.floor(cp / 4096), + 0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64) + end + return string.char(0xF0 + math.floor(cp / 262144), + 0x80 + math.floor(cp / 4096) % 64, 0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64) +end + +-- latin1ToUtf8(s):把「每字符=一原始字节」的串重按 UTF-8 解一次。 +-- +-- 这是参考 fixNicknameMojibake 里 `Buffer.from(raw,'latin1').toString('utf8')` +-- 的等价物:TRAE 回调的中文被错按 latin-1 解了一次,实测昵称变成 +-- `Óû§8847309959` 这类乱码。返回 (修复串, true) 或 (nil, false)。 +local function latin1ToUtf8(s) + local out, i, n = {}, 1, #s + while i <= n do + local b = string.byte(s, i) + if not b then break end + local cp, len + if b < 0x80 then cp, len = b, 1 + elseif b >= 0xC2 and b <= 0xDF then cp, len = b - 0xC0, 2 + elseif b >= 0xE0 and b <= 0xEF then cp, len = b - 0xE0, 3 + elseif b >= 0xF0 and b <= 0xF4 then cp, len = b - 0xF0, 4 + else return nil, false end + if i + len - 1 > n then return nil, false end + for k = 1, len - 1 do + local cb = string.byte(s, i + k) + if not cb or cb < 0x80 or cb > 0xBF then return nil, false end + cp = cp * 64 + (cb - 0x80) + end + -- 拒绝非最短编码、代理区与越界(否则会把垃圾字节当合法字符) + if (len == 2 and cp < 0x80) or (len == 3 and cp < 0x800) + or (len == 4 and cp < 0x10000) or cp > 0x10FFFF + or (cp >= 0xD800 and cp <= 0xDFFF) then + return nil, false + end + out[#out + 1] = utf8Encode(cp) + i = i + len + end + return table.concat(out), true +end + +-- hasCJK(s):是否含 CJK 汉字(UTF-8 三字节,U+4E00–U+9FFF)。 +-- 照参考 `/\u4e00-\u9fff/`:无 CJK 则判乱码,回退可读占位。 +local function hasCJK(s) + if type(s) ~= "string" then return false end + local i = 1 + while i <= #s do + local b = string.byte(s, i) + if b and b >= 0xE4 and b <= 0xE9 then + local b2, b3 = string.byte(s, i + 1), string.byte(s, i + 2) + if b2 and b3 and b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF then + local cp = (b - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80) + if cp >= 0x4E00 and cp <= 0x9FFF then return true end + end + end + i = i + 1 + end + return false +end + +-- traeFixNickname(raw, uid):照参考 fixNicknameMojibake 的两级处理。 +-- ① 先试 latin1→utf8 回转(结果含 CJK 才采用); +-- ② 不行且无 CJK → 「用户+uid末4位」占位(绝不把乱码写进凭据)。 +traeFixNickname = function(raw, uid) + if raw == nil or raw == "" then return raw or "" end + local fixed, ok = latin1ToUtf8(raw) + if ok and fixed ~= "" and hasCJK(fixed) then return fixed end + if not hasCJK(raw) then + return "\231\148\168\230\136\183" .. tostring(uid):sub(-4) + end + return raw +end + +-- traeCheckinHeaders(plat, cred, uid):签到/积分请求头。 +-- +-- 参考注释点明:简化头(仅 Authorization + 少量 Ug 头)**实测签到失败**; +-- 真正成功的是约 20 个客户端头。关键差别:X-Device-Id 用的是 +-- **基于 user_id 确定性派生的 15 位数字**(不是凭据里的 hex32)。 +-- 授权方案是 `Cloud-IDE-JWT`(不是 Bearer)—— 用错前缀是 401 级失败。 +-- 依赖:plat.checkin.auth 声明方案(trae 声明 cloud-ide-jwt)。 +traeCheckinHeaders = function(plat, cred, uid) + uid = uid or cred.uid or "" + if uid == "" then return nil, "缺少 user_id,无法构造设备身份" end + local h = { + ["Authorization"] = "Cloud-IDE-JWT " .. tostring(cred.access_token or ""), + ["Content-Type"] = "application/json", + ["Accept"] = "*/*", + ["Accept-Language"] = "zh-CN", + ["User-Agent"] = "VSCode 1.107.1 (TRAE SOLO CN)", + ["X-Market-Client-Id"] = "VSCode 1.107.1", + ["X-Market-User-Id"] = traeDeriveMarketUserId(uid), + ["X-User-Region"] = "CN", + ["X-Device-Id"] = traeDeriveDeviceId15(uid), + ["X-Lgw-Req-Sdk-Type"] = "3", + ["Package-Type"] = "stable_cn", + ["X-Lscbd-Aid"] = "787976", + ["X-Lscbd-Platform"] = "windows", + ["App-Version"] = plat.ide_version or "0.1.52", + ["Vscode-Sessionid"] = traeDeriveSessionId(uid), + ["Sec-Fetch-Dest"] = "empty", + } + -- 每次请求独立的 trace/request id(形如 00--01 / uuid v4) + h["X-Tt-Trace-Id"] = "00-" .. newToken():sub(1, 16) .. "-01" + local r = newToken() + h["X-Request-Id"] = r:sub(1, 8) .. "-" .. r:sub(9, 12) .. "-4" .. r:sub(14, 16) + .. "-8" .. r:sub(18, 20) .. "-" .. r:sub(21, 32) + return h, nil +end + +-- 表驱动签到推广到需自定义头的平台:在 PLATFORMS 定义后绑定。 +-- +-- credentialRef 是「秘密引用」,creds 是插件自解密后的 JSON(最可靠)。 +do + for _, p in ipairs(PLATFORMS) do + if p.checkin and p.checkin.auth == "cloud-ide-jwt" then + p.claim_fn = function(plat, tok, args) + local cred = (args and args.credential) or { access_token = tok } + local uid = (args and args.uid) or cred.uid + or (type(cred.user_id) == "string" and cred.user_id) or "" + local h, herr = traeCheckinHeaders(plat, cred, uid) + if herr then return nil, herr end + return { url = plat.checkin.url, method = plat.checkin.method or "POST", + headers = h, body = plat.checkin.body or "{}" } + end + end + end +end + +-- plugin.__qoder_machine_probe:只给测试用 —— 走真实的 headers_fn 路径, +-- 返回它给出的设备身份头,便于验证「成对出现」与「拿不到时降级」。 +-- plugin.__codearts_dpop_probe:只给测试用 —— 生成一把 DPoP 密钥并签一个 +-- 真实 JWS,供判据用标准 ES256 验签(验证 DER→raw 转换与 header/payload 形状)。 +-- ============================================================================ +-- CodeArts 专用适配器(插件在加载时**自己装**) +-- ============================================================================ +-- 为什么需要它:CodeArts 的推理端点要 **华为云 AK/SK 签名** +-- (SDK-HMAC-SHA256),且签名必须覆盖 method/uri/query/canonical-headers/ +-- payload-hash —— 这不是"改个 endpoint 或加个静态头"能表达的,通用 openai +-- 适配器做不了。 +-- +-- 为什么由插件装而不是做成内置适配器:CodeArts 的接入是本插件引入的能力 +-- (登录 + 凭据 + 源注册都在这里),且内核纪律是"零厂商名"——内置适配器 +-- 里出现 codearts 就破了这条线。插件自装正好把厂商知识留在插件域。 +-- +-- 与内置适配器的差别(照参考 llm-adapter.ts 的 CodeArtsAdapter): +-- * build_headers 动态签名(凭据从 meta.api_key 解出 AK/SK/ST) +-- * 端点是 /api/v2/chat/completions(含 /api/v2 前缀) +-- * Agent-Type: PromptCenter / X-Language: zh-cn(**签名后**追加, +-- 不参与 canonical —— 参考注释点名) +local CODEARTS_ADAPTER_SRC = [==[ +local adapter = {} +adapter.name = "codearts" +adapter.version = "1.0.0" +-- ⚠️ 源注册时会用平台声明的 endpoint 覆盖;这里保持一致以免两处漂移。 +adapter.endpoint = "/api/v2/chat/completions" + +-- 华为云 SDK-HMAC-SHA256 签名。逐条对齐参考 sign.ts:signRequestHuawei: +-- canonical = METHOD\nURI\nQUERY\nCANONICAL_HEADERS\n\nSIGNED_HEADERS\nPAYLOAD_HASH +-- string_to_sign = "SDK-HMAC-SHA256\n" .. x-sdk-date .. "\n" .. sha256(canonical) +-- signature = HMAC-SHA256(SK, string_to_sign) +-- 三个易错点(参考注释都点名了): +-- 1. URI 末尾**必须补 /**(否则签名不匹配) +-- 2. Authorization 用 `Access=` 不是 `Credential=` +-- 3. Agent-Type / X-Language 是**签名后追加**的,不进 canonical +-- signed_parts:返回 (headers, canonical, string_to_sign)。 +local function signed_parts(method, url, cred, body) + local ak = cred.access_key_id or cred.access_key or "" + local sk = cred.secret_access_key or cred.secret_key or "" + local st = cred.security_token or "" + if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end + local host = url:match("^https?://([^/]+)") + local uri = url:match("^https?://[^/]+(/[^?]*)") or "/" + if uri:sub(-1) ~= "/" then uri = uri .. "/" end + local query = url:match("%?([^#]*)$") or "" + local payload = body or "" + local payload_hash = sha256_hex(payload) + local date = os.date("!%Y%m%dT%H%M%SZ") + + local h = { + ["host"] = host, + ["x-sdk-date"] = date, + ["x-sdk-content-sha256"] = payload_hash, + } + if st ~= "" then h["x-security-token"] = st end + if method:upper() ~= "GET" then h["content-type"] = "application/json" end + + local keys = {} + for k in pairs(h) do keys[#keys + 1] = k end + table.sort(keys) + local lines, names = {}, {} + for _, k in ipairs(keys) do + lines[#lines + 1] = k .. ":" .. h[k] + names[#names + 1] = k + end + local signed = table.concat(names, ";") + local canonical = table.concat({ + method:upper(), uri, query, table.concat(lines, "\n"), "", + signed, payload_hash, + }, "\n") + local sts = "SDK-HMAC-SHA256\n" .. date .. "\n" .. sha256_hex(canonical) + local sig = hmac_sha256_hex(sk, sts) + + local out = {} + for _, k in ipairs(keys) do + if k ~= "host" then out[k] = h[k] end + end + out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak + .. ",SignedHeaders=" .. signed .. ",Signature=" .. sig + -- 签名后追加:不参与 canonical(参考注释点名) + out["Agent-Type"] = "PromptCenter" + out["X-Language"] = "zh-cn" + return out, canonical, sts +end + +-- 兼容旧调用名(build_headers 只用 headers) +local function signed_headers(method, url, cred, body) + local out = signed_parts(method, url, cred, body) + return out +end + +-- sign_parts(method,url,cred,body):把签名**中间值**返回给判据用。 +-- 与 build_headers 共用同一个 signed_headers(不重复实现 —— 判据必须比对 +-- 真实函数算出的 canonical / string_to_sign,自算版会掩盖真实签名被改坏)。 +function adapter.sign_parts(method, url, cred, body) + local out, canonical, sts = signed_parts(method, url, cred, body) + if not out then return nil, canonical end + return out, canonical, sts +end + +-- build_headers:内核按源注入 meta{url,method,body,api_key,...}。 +-- 凭据是完整 JSON(登录时存的就是它),从中解出 AK/SK/ST。 +function adapter.build_headers(meta) + local h = { ["Content-Type"] = "application/json" } + local raw = tostring(meta.api_key or "") + local cred = nil + if raw:sub(1, 1) == "{" then + local ok, rec = pcall(json.decode, raw) + if ok and type(rec) == "table" then cred = rec end + end + if cred == nil then + -- 裸 token 形态(兼容旧数据):无法签名,如实说明 + h["X-Channels-Error"] = "codearts 需要完整凭据 JSON(AK/SK),当前是裸 token" + return h + end + local signed, err = signed_headers(meta.method or "POST", meta.url or "", cred, meta.body) + if not signed then + h["X-Channels-Error"] = tostring(err) + return h + end + for k, v in pairs(signed) do h[k] = v end + return h +end + +-- 请求体:CodeArts 是标准 OpenAI 兼容,只需剥内核私有字段。 +function adapter.transform_request(raw_body) + local req = __prelude.decode_body(raw_body) + if req == nil then return raw_body end + __prelude.strip_control(req) + req.model = req.model or "CodeArts-Pangu" + req.stream = req.stream or false + return json.encode(req) +end + +function adapter.transform_response(raw_body) + return __prelude.openai_response(raw_body) +end + +function adapter.transform_stream_chunk(raw_chunk) + return __prelude.openai_stream_chunk(raw_chunk) +end + +function adapter.transform_error(status, body) + return __prelude.openai_error(status, body) +end + +return adapter +]==] + +-- ============================================================================ +-- TRAE 专用适配器(插件自装:channels-trae) +-- ============================================================================ +-- TRAE **不是 OpenAI 兼容**。参考 trae.ts:30 注释点名: +-- 「载荷必须转换:OpenAI 的 {model,messages,tools,tool_choice,stream} 需要 +-- 映射为 SOLO 格式(function、config_name 等字段),不能透传」 +-- +-- 为什么名字不叫 "trae":内置适配器 `trae.lua` 服务的是**运维自建的 +-- trae-local-api 反代**(base_url 127.0.0.1:19900,生产 config.yaml 在用), +-- 与本插件的直连通道是两回事。重名会被内核的冲突仲裁挂起(正确的护栏), +-- 所以本插件用 `channels-trae`。 +-- +-- SOLO 转换规则(逐条对齐参考 trae.ts:transformToSOLOBody): +-- 1. messages.content 字符串 → [{type:"text",text:...}];已是数组则透传 +-- 2. stream 强制 true(非流式由服务端聚合) +-- 3. model → config_name + model **双字段** +-- 4. function: 通道名,缺省 "solo_work_lite" +-- 5. tool_choice 归一化("none" 删 tools;auto/required 保留; +-- {type:"function",function:{name}} → 字符串 name) +-- 6. assistant 消息的 tool_calls: function → function_call(SOLO 字段名), +-- 并剔除没有 name 的条目(孤儿会让整条会话被拒) +-- 7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求) +local TRAE_SOLO_ADAPTER_SRC = [==[ +local adapter = {} +adapter.name = "channels-trae" +adapter.version = "1.0.0" +adapter.endpoint = "/api/agent/v3/llm_utils_chat" + +local TRAE_DEFAULT_MODEL = "glm-5.2" +local TRAE_FUNCTION = "solo_work_lite" +local TRAE_MAX_MODE_TYPE = 1 +local TRAE_MAX_PROMPT_TOKENS = 936000 +local TRAE_MAX_OUTPUT_TOKENS = 64000 + +-- normalizeToolChoice:照参考 normalizeToolChoice(Go payload.go:111-154) +local function normalize_tool_choice(body) + local tc = body.tool_choice + if tc == nil then return end + local function suppress() + body.tools = nil + body.functions = nil + end + if type(tc) == "string" then + if string.lower(tc):gsub("%s+", "") == "none" then + body.tool_choice = nil + suppress() + end + return + end + if type(tc) == "table" then + local typ = type(tc.type) == "string" and string.lower(tc.type):gsub("%s+", "") or "" + if typ == "none" then + body.tool_choice = nil + suppress() + elseif typ == "auto" or typ == "required" then + body.tool_choice = typ + elseif typ == "function" then + local name = "" + local fn = tc["function"] + if type(fn) == "table" and type(fn.name) == "string" then name = fn.name end + if name == "" and type(tc.name) == "string" then name = tc.name end + if #(name:gsub("%s+", "")) > 0 then body.tool_choice = name + else body.tool_choice = "auto" end + else + body.tool_choice = nil + end + return + end + body.tool_choice = nil +end + +-- normalizeTools:parameters 必须是**字符串**(SOLO 要求),OpenAI 标准是对象。 +local function normalize_tools(body) + local raw = body.tools + if type(raw) ~= "table" or #raw == 0 then return end + local out = {} + for _, item in ipairs(raw) do + if type(item) == "table" then + local fn = item["function"] + if type(fn) == "table" then + if type(fn.parameters) == "table" then + fn.parameters = json.encode(fn.parameters) + end + out[#out + 1] = item + end + end + end + if #out > 0 then body.tools = out end +end + +-- transformSOLOMessage:单条消息(content 字符串→parts;tool_calls 字段改名) +local function transform_solo_message(msg) + if type(msg) ~= "table" then return msg end + local result = {} + for k, v in pairs(msg) do result[k] = v end + + if result.role == "assistant" and type(result.tool_calls) == "table" then + local kept = {} + for _, tc in ipairs(result.tool_calls) do + if type(tc) == "table" then + -- function → function_call(SOLO 字段名) + if type(tc["function"]) == "table" then + tc.function_call = tc["function"] + tc["function"] = nil + end + local fc = tc.function_call + local name = (type(fc) == "table" and type(fc.name) == "string") and fc.name or "" + -- 剔除没有 name 的条目(孤儿 tool_call 会让整条会话被拒) + if #(name:gsub("%s+", "")) > 0 then kept[#kept + 1] = tc end + end + end + if #kept > 0 then result.tool_calls = kept else result.tool_calls = nil end + end + + -- content:字符串 → [{type:"text",text:...}];数组/其它原样透传 + if type(result.content) == "string" then + result.content = { { type = "text", text = result.content } } + end + return result +end + +-- transform_request:OpenAI 请求体 → SOLO 格式 +function adapter.transform_request(raw_body) + local body = __prelude.decode_body(raw_body) + if body == nil then return raw_body end + __prelude.strip_control(body) + + body.stream = true + -- ⚠️ `function` 是 Lua 关键字:必须用 body["function"],写 body.function + -- 是语法错误('' expected near 'function'),且只在 load() 时才暴露 + -- —— 模块级 local function 不会在编译期报出来。 + if body["function"] == nil then body["function"] = TRAE_FUNCTION end + if type(body["function"]) == "string" and body["function"] == "" then + body["function"] = TRAE_FUNCTION + end + + -- messages 转换 + if type(body.messages) == "table" then + local msgs = {} + for _, m in ipairs(body.messages) do msgs[#msgs + 1] = transform_solo_message(m) end + body.messages = msgs + end + + -- model → config_name + model(双字段)。支持 __dev 后缀消除。 + local model = type(body.model) == "string" and body.model or "" + local base = model + local sep = string.find(model, "__", 1, true) + if sep then base = string.sub(model, 1, sep - 1) end + local config_name = (base ~= "" and base) or TRAE_DEFAULT_MODEL + body.config_name = config_name + body.model = config_name + + normalize_tool_choice(body) + normalize_tools(body) + return json.encode(body) +end + +function adapter.transform_response(raw_body) + return __prelude.openai_response(raw_body) +end + +function adapter.transform_stream_chunk(raw_chunk) + return __prelude.openai_stream_chunk(raw_chunk) +end + +function adapter.transform_error(status, body) + return __prelude.openai_error(status, body) +end + +return adapter +]==] + +-- ============================================================================ +-- ============================================================================ +-- loomy 领取:触发每日额度重置(照参考 loomy-credits.ts:claimLoomyDailyQuota) +-- ============================================================================ +-- ⚠️ 语义是「触发每日额度重置」,**不是**「+5000 积分」: +-- dailyBalance = dailyQuota - dailyConsumed,消耗后不回补。 +-- ⚠️ 幂等判据是 `alreadyProcessed`(重复调用同样 HTTP 200)→ 映射成 +-- already-claimed,不是 claimed(谎报收益比报错更糟)。 +-- 本函数不抛错:失败也返回 failed,保证批量领取不因单个账号中断。 +function claimLoomy(plat, tok, args) + local cred = (args and args.credential) or nil + local base = (cred and (cred.api_base or cred.apiBase)) or "https://loomy.xunfei.cn" + local root = base:gsub("/chat/completions$", "") + local url = root .. "/points/first-login" + local resp, err = __llmsproxy_http({ + url = url, method = "POST", + headers = { + ["Authorization"] = "Bearer " .. tok, + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + }, + body = "{}", + }) + if err or not resp then + return { done = true, kind = "failed", message = "领取失败: " .. tostring(err) } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then rec = {} end + if resp.status ~= 200 then + local msg = rec.message or rec.msg or "" + return { done = true, kind = "failed", + message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) } + end + local data = rec.data or rec + if type(data) ~= "table" then data = {} end + local dailyQuota = tonumber(data.dailyQuota) + local dailyBalance = tonumber(data.dailyBalance) + if data.alreadyProcessed == true then + return { done = true, kind = "already-claimed", + message = dailyQuota and + string.format("今日额度已初始化(每日 %s/%s)", + tostring(dailyBalance or 0), tostring(dailyQuota)) + or "今日额度已初始化" } + end + local dailyConsumed = tonumber(data.dailyConsumed) + local granted = 0 + if dailyQuota and dailyConsumed then + granted = math.max(0, dailyQuota - dailyConsumed) + end + return { done = true, kind = "claimed", credit = granted, unit = "quota" } +end + +-- ============================================================================ +-- zcode 领取:每日额度(照参考 zcode-upstream.ts:claimZcodePlan) +-- ============================================================================ +-- 三步(照参考 zcode-auth.ts:claimDailyWith 的顺序): +-- ① 补激活上报(event/report,**不需要 Authorization**) +-- ② preview 查可领 plan(`?app_version=&platform=win32`) +-- ③ 逐个 plan POST billing/claim,body={plan_id} +-- +-- ⚠️ claim **始终强制索要 captcha**(上游校验前置于 plan 校验)。参考的 +-- captcha 有两个来源:DSH Desktop 的 webview guest 产的 param("内部载体"), +-- 或外挂 headful Chromium。**我们没有那个桌面壳** ⇒ 只能走浏览器路径。 +-- +-- ⇒ 因此: +-- * 本函数在**没有 captcha param 时如实失败**(不伪造、不重试), +-- 并给出可操作提示 —— 谎报"已领取"比报错更糟。 +-- * auto_claim 已把 zcode 列进 auto_exclude(照参考 +-- isAutoCheckinExcluded):代价发生在调用期间(拉 headful Chromium + +-- 阿里云按设备限流 150 次/小时),"等它报错再判定"来不及。 +-- * 用户**手动**点领取时带上 captcha param 即可走通(面板交互)。 +local ZCODE_ORIGIN = "https://zcode.z.ai" + +-- zcodeHeaders:照参考 buildZcodeHeaders。X-Device-Mid 是**硬需求** +-- (缺它 billing 全家桶回 400)。 +local function zcodeHeaders(cred, opts) + opts = opts or {} + local h = { + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["X-Device-Mid"] = tostring(cred.device_mid or ""), + ["X-App-Version"] = tostring(cred.app_version or "1.0.0"), + ["X-Client-Platform"] = "win32", + } + if opts.authorization then h["Authorization"] = opts.authorization end + if opts.captcha then + h["X-Captcha-Param"] = opts.captcha.param + h["X-Captcha-Region"] = opts.captcha.region or "" + end + return h +end + +-- zcodeClaimablePlans(cred):查可领 plan(preview)。 +local function zcodeClaimablePlans(cred) + local av = tostring(cred.app_version or "1.0.0") + local url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview" + .. "?app_version=" .. rfc3986Escape(av) .. "&platform=win32" + local resp, err = __llmsproxy_http({ + url = url, method = "GET", + headers = zcodeHeaders(cred, { + authorization = "Bearer " .. tostring(cred.zcode_jwt or cred.access_token or ""), + }), + }) + if err or not resp then return nil, "查询可领计划失败: " .. tostring(err) end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then return nil, "响应不是 JSON" end + local plans = rec.plans or (rec.data and rec.data.plans) or {} + local out = {} + for _, pl in ipairs(plans) do + if type(pl) == "table" then + local id = pl.plan_id or pl.planId + if id then out[#out + 1] = tostring(id) end + end + end + return out, nil +end + +function claimZcode(plat, tok, args) + local cred = (args and args.credential) or {} + local jwt = cred.zcode_jwt or cred.access_token or tok + cred = cred or {} + if not cred.zcode_jwt and not cred.access_token then cred.zcode_jwt = tok end + + -- ① 补激活上报(照参考:不需要 Authorization) + __llmsproxy_http({ + url = ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST", + headers = { ["Content-Type"] = "application/json" }, + body = json.encode({ event = "activation", app_version = tostring(cred.app_version or "1.0.0") }), + }) + + -- ② 查可领 plan + local plans, perr = zcodeClaimablePlans(cred) + if not plans then + return { done = true, kind = "failed", message = perr } + end + if #plans == 0 then + -- ⚠️ 照参考:「没有可领 plan」**不等于**「今天已领」—— 也可能活动未投放。 + -- 说成 already-claimed 是谎报。 + return { done = true, kind = "inactive", + message = "当前没有可领取的计划(可能活动未投放,或今日已领)" } + end + + -- ③ 每个 plan 都要一个 captcha param。没有就如实失败。 + local captcha = args and args.captcha + if type(captcha) ~= "table" or not captcha.param or captcha.param == "" then + return { done = true, kind = "action_required", + message = "zcode 领取需要验证码参数(captcha param)。" + .. "本机无 DSH Desktop 内部载体,需在面板手动领取(会拉起浏览器);" + .. "自动签到已按参考把它排除。" } + end + + local granted, failedMsg = 0, nil + for _, planId in ipairs(plans) do + local resp, err = __llmsproxy_http({ + url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST", + headers = zcodeHeaders(cred, { + authorization = "Bearer " .. tostring(jwt), + captcha = { param = captcha.param, region = captcha.region or "cn" }, + }), + body = json.encode({ plan_id = planId }), + }) + if err or not resp then + failedMsg = "领取失败: " .. tostring(err) + else + local ok, rec = pcall(json.decode, resp.body or "") + if ok and type(rec) == "table" then + -- ⚠️ 业务码可能是**纯数字字符串**("3012"),tonumber 必须能认 + local code = tonumber(rec.code) or tonumber(tostring(rec.code or "")) + local msg = tostring(rec.msg or rec.message or "") + if code == 3012 or msg:find("unusual activity", 1, true) then + failedMsg = "上游风控拦截(3012 unusual activity)—— 需重新获取验证码" + elseif code and code ~= 0 and code ~= 200 then + failedMsg = msg ~= "" and msg or ("业务码 " .. tostring(code)) + else + granted = granted + 1 + end + else + failedMsg = "响应不是 JSON(HTTP " .. tostring(resp.status) .. ")" + end + end + end + + if granted == 0 then + return { done = true, kind = "failed", message = failedMsg or "全部计划领取失败" } + end + return { done = true, kind = "claimed", credit = granted, unit = "plans", + message = ("已领取 " .. granted .. "/" .. #plans .. " 个计划") + .. (failedMsg and ("(部分失败:" .. failedMsg .. ")") or "") } +end + +-- raccoon 领取:桌面端登录奖励(照参考 raccoon-credits.ts:claimRaccoonLoginReward) +-- ============================================================================ +-- 端点:POST {apiBase}/api/web/desktop/v1/login/points/grant +-- ⚠️ 幂等判据是响应里的 `granted` 字段:**重复领取同样返回 HTTP 200 但 +-- granted=false**。据此映射成 already-claimed,而不是 claimed —— +-- 否则用户会以为每天都真的加了额度(谎报收益比报错更糟)。 +-- 失败时若上游给了 message,用它(比通用文案有用)。 +function claimRaccoon(plat, tok, args) + local cred = (args and args.credential) or nil + local base = (cred and (cred.api_base or cred.apiBase)) or plat.base_url or "" + -- base_url 在平台表里含 llm 前缀(.../api/web/llm/v2),领取端点不在这条路径上, + -- 故从凭据或平台根域名取。参考用 product.apiBase + RACCOON_DESKTOP_PREFIX。 + local root = base:gsub("/api/web/llm/v2$", "") + local url = root .. "/api/web/desktop/v1/login/points/grant" + local h = { + ["Authorization"] = "Bearer " .. tok, + ["Accept"] = "application/json", + ["Content-Type"] = "application/json", + ["X-Org-Code"] = (cred and (cred.office_identity or "")) or "", + ["X-Raccoon-Language"] = "zh", + } + if cred and cred.device_id and cred.device_id ~= "" then + h["X-Device-Id"] = cred.device_id + end + local resp, err = __llmsproxy_http({ url = url, method = "POST", headers = h, body = "{}" }) + if err or not resp then + return { done = true, kind = "failed", + message = "领取请求失败: " .. tostring(err) } + end + local ok, rec = pcall(json.decode, resp.body or "") + if not ok or type(rec) ~= "table" then rec = {} end + -- 信封:{code, message, data:{granted, popup:{points}}} + local env = rec.data and rec or rec + if resp.status ~= 200 or (rec.code and rec.code ~= 0 and rec.code ~= 200) then + local msg = rec.message or rec.msg or "" + return { done = true, kind = "failed", + message = (msg ~= "" and tostring(msg)) or ("HTTP " .. tostring(resp.status)) } + end + local data = rec.data or env + if type(data) ~= "table" or data.granted ~= true then + -- 幂等:HTTP 200 但 granted 非 true = 已经领过(每号一次) + return { done = true, kind = "already-claimed", + message = "该账号已领取过桌面端登录奖励(每号一次)" } + end + local points = tonumber((data.popup or {}).points) or 3000 + return { done = true, kind = "claimed", credit = points, unit = "points" } +end + +-- installRaccoonAdapter +local function installTraeAdapter() + if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + -- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。 + local f, lerr = load(TRAE_SOLO_ADAPTER_SRC) + if not f then return false, "源码语法错误: " .. tostring(lerr) end + local _, uerr = __llmsproxy_adapters.update("channels-trae", TRAE_SOLO_ADAPTER_SRC) + if uerr then + local _, ierr = __llmsproxy_adapters.install("channels-trae", TRAE_SOLO_ADAPTER_SRC) + if ierr then return false, ierr end + end + return true, nil +end + +-- ============================================================================ +-- Gemini 专用适配器(插件自装:channels-gemini) +-- ============================================================================ +-- Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent。 +-- 参考 gemini.ts 点名三件必须保留的细节: +-- 1. **身份五头逐字写死、不许随机**,且**不带** x-goog-api-key / +-- x-goog-api-client(参考 GEMINI_IDENTITY_HEADERS) +-- 2. **流式刻意不带 Accept 头**(抓包一致) +-- 3. **信封逐层字母序序列化**(marshalAlphabetical)—— Go 的 encoding/json +-- 对 map 自动字母序,而我们的适配器用 json.encode,故须显式排序 +-- +-- 信封形状(私有端点要的不是裸 contents): +-- {model, project, request:{contents,systemInstruction,tools, +-- generationConfig,sessionId}, requestId, userAgent} +-- 响应也是信封(candidates 嵌在 response 里),必须剥一层。 +-- +-- ⛔ 不做:gemini-sigstore(签名持久化,268 行)。我们推理走无状态适配器, +-- 插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传错误。 +local GEMINI_PRIVATE_ADAPTER_SRC = [==[ +local adapter = {} +adapter.name = "channels-gemini" +adapter.version = "1.0.0" +adapter.endpoint = "/v1internal:generateContent" +adapter.endpoint_stream = "/v1internal:streamGenerateContent?alt=sse" + +-- 身份五头逐字写死(参考 GEMINI_IDENTITY_HEADERS)。**不许随机**: +-- 上游按客户端指纹识别,随机化会被判异常。 +adapter.headers = { + ["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", +} +-- ⚠️ 刻意**不带** Accept(参考:流式请求不带 Accept,抓包一致)。 +-- 也不需要 x-goog-api-key(Cloud Code 用 OAuth Bearer)。 + +local GEMINI_DEFAULT_PROJECT = "aicode-consumers" +local GEMINI_UPSTREAM_USER_AGENT = "antigravity" + +-- 字母序 JSON 序列化(参考 marshalAlphabetical → sortedStringify)。 +-- 为什么必须排序:上游按**字节**比对/缓存,键序不同即不同请求。Go 的 +-- encoding/json 对 map 自动字母序,Lua 的 json.encode 不排序 —— 故自己排。 +local function sorted_json(v) + local t = type(v) + if v == nil then return "null" end + if t == "boolean" then return v and "true" or "false" end + if t == "number" then + if v == math.floor(v) and math.abs(v) < 1e15 then return string.format("%d", v) end + return tostring(v) + end + if t == "string" then return json.encode(v) end + if t == "table" then + -- 数组(1..n 连续整数键)→ 保序;对象 → 键排序 + local n = 0 + for k in pairs(v) do + if type(k) ~= "number" then n = -1 break end + if k > n then n = k end + end + if n >= 0 then + local parts = {} + for i = 1, n do parts[#parts + 1] = sorted_json(v[i]) end + return "[" .. table.concat(parts, ",") .. "]" + end + local keys = {} + for k in pairs(v) do keys[#keys + 1] = tostring(k) end + table.sort(keys) + local parts = {} + for _, k in ipairs(keys) do + parts[#parts + 1] = json.encode(k) .. ":" .. sorted_json(v[k]) + end + return "{" .. table.concat(parts, ",") .. "}" + end + return "null" +end + +-- 首条 user 文本(sessionId 的派生输入之一) +local function first_user_text(contents) + if type(contents) ~= "table" or #contents == 0 then return "" end + local parts = contents[1] and contents[1].parts + if type(parts) ~= "table" then return "" end + for _, p in ipairs(parts) do + if type(p) == "table" and type(p.text) == "string" then return p.text end + end + return "" +end + +-- sessionId = f(project, 首条 user 文本)。参考明言:数值不与原版逐字相同 +-- (哈希本体未反推出来),复刻的是**依赖维度与"同输入同输出"**。 +-- 这点很重要:不要假装数值一致;判据只能断言确定性(同输入同输出、 +-- 换 project/文本会变、换 model 不变)。 +local function derive_session_id(project, text) + -- ⚠️ 本 Lua 构建**不支持按位异或 `~`**(load 时报 unexpected symbol near '~')。 + -- FNV-1a 的 XOR 用「加等价类」替代:这里要的不是 FNV 的精确值(参考自己 + -- 也明言数值不与原版逐字相同,哈希本体未反推出来),而是**确定性 + 依赖 + -- 正确的维度**(吃 project 与首条文本、不吃 model)。 + local h = 2166136261 + local input = tostring(project) .. "\\0" .. tostring(text) + for i = 1, #input do + h = (h + string.byte(input, i) * 2654435761) % 4294967296 + h = (h * 16777619) % 4294967296 + end + return tostring(h) +end + +function adapter.transform_request(raw_body) + local req = __prelude.decode_body(raw_body) + if req == nil then return raw_body end + __prelude.strip_control(req) + + -- ① 先把 OpenAI 消息转成 Gemini contents(复用既有逻辑的等价实现) + local contents, system = {}, "" + local call_names = {} + local function to_parts(content) + if type(content) == "string" then + if content == "" then return {} end + return { { text = content } } + end + local out = {} + for _, p in ipairs(content or {}) do + if type(p) == "string" then + if p ~= "" then out[#out + 1] = { text = p } end + elseif type(p) == "table" then + if p.type == "text" and p.text ~= nil and p.text ~= "" then + out[#out + 1] = { text = p.text } + elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then + local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$") + if b64 then + out[#out + 1] = { inlineData = { mimeType = mt or "image/png", data = b64 } } + end + end + end + end + return out + end + + for _, m in ipairs(req.messages or {}) do + local role = m.role or "user" + if role == "system" then + if type(m.content) == "string" then system = system .. m.content .. "\n" end + elseif role == "assistant" then + local parts = to_parts(m.content) + if type(m.tool_calls) == "table" then + for _, tc in ipairs(m.tool_calls) do + if type(tc) == "table" and type(tc["function"]) == "table" then + call_names[tostring(tc.id)] = tc["function"].name + local args = {} + local ok, decoded = pcall(json.decode, tc["function"].arguments or "{}") + if ok and type(decoded) == "table" then args = decoded end + parts[#parts + 1] = { functionCall = { name = tc["function"].name, args = args } } + end + end + end + if #parts > 0 then contents[#contents + 1] = { role = "model", parts = parts } end + elseif role == "tool" then + local name = call_names[tostring(m.tool_call_id or "")] or "" + contents[#contents + 1] = { role = "user", parts = { { + functionResponse = { name = name, response = { output = tostring(m.content or "") } }, + } } } + else + local parts = to_parts(m.content) + if #parts > 0 then contents[#contents + 1] = { role = "user", parts = parts } end + end + end + + -- ② 组装 request + local greq = { contents = contents } + if system ~= "" then + greq.systemInstruction = { role = "user", parts = { { text = system } } } + end + if type(req.tools) == "table" then + local decls = {} + for _, t in ipairs(req.tools) do + if type(t) == "table" and type(t["function"]) == "table" then + local fn = t["function"] + local params = fn.parameters + if type(params) ~= "table" then params = { type = "object", properties = {} } end + decls[#decls + 1] = { + name = fn.name or "", description = fn.description or "", parameters = params, + } + end + end + if #decls > 0 then greq.tools = { { functionDeclarations = decls } } end + end + local gen = {} + if req.max_tokens then gen.maxOutputTokens = req.max_tokens end + if req.temperature then gen.temperature = req.temperature end + if req.top_p then gen.topP = req.top_p end + if next(gen) then greq.generationConfig = gen end + + local project = (req.project and tostring(req.project)) or GEMINI_DEFAULT_PROJECT + greq.sessionId = derive_session_id(project, first_user_text(contents)) + + -- ③ 信封(字母序序列化) + local envelope = { + model = req.model or "", + project = project, + request = greq, + requestId = "agent/" .. tostring(os.time()) .. "/" .. string.sub(sha256_hex(tostring(os.clock())), 1, 8), + userAgent = GEMINI_UPSTREAM_USER_AGENT, + } + return sorted_json(envelope) +end + +-- 响应也是信封:{response:{candidates:[…]}} —— 必须剥一层。 +-- 不剥的话下游拿不到 candidates,表现为"模型没回话"。 +local function unwrap(raw, decode_fn) + local rec = __prelude.decode_body(raw) + if rec == nil then return raw end + if type(rec.response) == "table" then return rec.response end + return rec +end + +function adapter.transform_response(raw_body) + local inner = unwrap(raw_body) + if type(inner) == "string" then return inner end + return __prelude.gemini_response(json.encode(inner)) +end + +function adapter.transform_stream_chunk(raw_chunk) + local inner = unwrap(raw_chunk) + if type(inner) == "string" then return inner end + return __prelude.gemini_stream_chunk(json.encode(inner)) +end + +function adapter.transform_error(status, body) + return __prelude.openai_error(status, body) +end + +-- 判据用:把派生逻辑暴露出来(不重复实现,避免判据与被测对象脱节) +function adapter.derive_session_id(project, text) + return derive_session_id(project, text) +end +function adapter.sorted_json(v) + return sorted_json(v) +end + +return adapter +]==] + +-- installGeminiAdapter():装/更新本插件自己的 Gemini 私有协议适配器。 +local function installGeminiAdapter() + if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + -- 先在本地编译一次,把语法错误在**装载失败之前**变成可读信息 + -- (load 失败只给"源码无法编译",不给行号) + local f, lerr = load(GEMINI_PRIVATE_ADAPTER_SRC) + if not f then return false, "源码语法错误: " .. tostring(lerr) end + local _, uerr = __llmsproxy_adapters.update("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC) + if uerr then + local _, ierr = __llmsproxy_adapters.install("channels-gemini", GEMINI_PRIVATE_ADAPTER_SRC) + if ierr then return false, ierr end + end + return true, nil +end + +-- installCodeartsAdapter():装/更新本插件自己的 codearts 适配器。 +-- 幂等(update 失败时退化为 install);失败只记日志,不阻断插件加载 —— +-- 适配器装不上时源注册会报"未知适配器",比让整个插件加载失败更好定位。 +local function installCodeartsAdapter() + if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + -- 本地预编译:装载失败只回"源码无法编译",不给行号;load 失败给行号。 + local f, lerr = load(CODEARTS_ADAPTER_SRC) + if not f then return false, "源码语法错误: " .. tostring(lerr) end + local _, uerr = __llmsproxy_adapters.update("codearts", CODEARTS_ADAPTER_SRC) + if uerr then + local _, ierr = __llmsproxy_adapters.install("codearts", CODEARTS_ADAPTER_SRC) + if ierr then return false, ierr end + end + return true, nil +end + +-- plugin.__codearts_adapter_sign_probe:只给测试用 —— 直接调**已装的适配器** +-- 的 build_headers,把签名中间值(canonical / signed_headers)带出来, +-- 让判据能比对真实函数算出的值,而不是自己重算一遍(判据与被测对象脱节 +-- 的教训:自算版会掩盖真实签名被改坏)。 +function plugin.__codearts_adapter_sign_probe(args) + args = args or {} + -- 从适配器池里取 build_headers 的结果不易(插件 VM 与适配器 VM 分离), + -- 故用**同一份源码**在本 VM 里求值后调用 —— 源码是单一定义(上面那个 + -- CODEARTS_ADAPTER_SRC 常量),不存在两处漂移。 + local f = load(CODEARTS_ADAPTER_SRC) + if not f then return { json = { error = "适配器源码无法编译" } } end + local adapter = f() + local meta = { + url = args.url, method = args.method or "POST", + body = args.body or "", api_key = args.api_key or "", + } + local h = adapter.build_headers(meta) + local out = { authorization = h["Authorization"] } + for k, v in pairs(h) do out["hdr_" .. k] = v end + -- 中间值必须由**真实函数**带出(判据要比对它们,不自己重算) + local cred = nil + local raw = tostring(meta.api_key or "") + if raw:sub(1, 1) == "{" then + local ok2, rec = pcall(json.decode, raw) + if ok2 and type(rec) == "table" then cred = rec end + end + if cred and adapter.sign_parts then + local _, canon, sts = adapter.sign_parts(meta.method, meta.url, cred, meta.body) + out.canonical = canon + out.string_to_sign = sts + end + return { json = out } +end + +-- plugin.__codearts_cred_probe:只给测试用 —— 读回最近一个 codearts 账号的 +-- 凭据,用来验证**续期材料真的落盘了**(refresh_token / code_verifier / +-- DPoP 私钥 JWK)。只断言"登录返回成功"无法防止凭据被截断成裸 AK/SK。 +function plugin.__codearts_cred_probe(args) + -- 先看账号池条目(新路径),再退到旧单账号引用(channels:)。 + local refs = {} + local list = plugin.account_list({ platform = "codearts" }) + local j = list and list.json + for _, a in ipairs((j and j.accounts) or {}) do + if a.credentialRef then refs[#refs + 1] = a.credentialRef end + end + refs[#refs + 1] = "channels:codearts" + for _, ref in ipairs(refs) do + local cred = credentialFull(ref) + if type(cred) == "table" then + return { json = { + ref = ref, + access_key_id = cred.access_key_id, + refresh_token = cred.refresh_token, + code_verifier = cred.code_verifier, + dpop_private_key_jwk = cred.dpop_private_key_jwk, + } } + end + end + return { json = { error = "未找到 codearts 凭据", refs = refs } } +end + +function plugin.__codearts_dpop_probe(args) + args = args or {} + local key, kerr = codeartsNewDpop() + if not key then return { json = { error = tostring(kerr) } } end + local jws, serr = codeartsDpopSign(key, tostring(args.htm or "POST"), + tostring(args.htu or "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens")) + if not jws then return { json = { error = tostring(serr) } } end + return { json = { jws = jws } } +end + +-- plugin.__qoder_refresh_probe(nowMs):只给测试用 —— 用**注入的时刻**验证 +-- UTC+8 刷新判定(本机恰是 UTC+8,否则无法区分"算术平移"与"本机时区")。 +function plugin.__qoder_refresh_probe(args) + local ms = tonumber((type(args) == "table" and args.now_ms) or 0) or 0 + return { json = { refreshed = qoderCampaignRefreshed(ms) } } +end + +function plugin.__qoder_machine_probe(args) + local ph = nil + for _, p in ipairs(PLATFORMS) do + if p.id == "qoder" then ph = p break end + end + if not ph or not ph.headers_fn then return { json = { error = "qoder headers_fn 缺失" } } end + local h = ph.headers_fn("TOK", {}) + return { json = { token = h["Cosy-MachineToken"], type = h["Cosy-MachineType"], + client_type = h["Cosy-ClientType"] } } +end + +-- plugin.__codearts_sign_probe:只给测试做差分(与参考生成的期望值比对)。 +-- 允许传入 date 以对齐参考的固定时间戳。 +function plugin.__codearts_sign_probe(args) + args = args or {} + local date = tostring(args.date or "") + local saved = codeartsDateStamp + if date ~= "" then codeartsDateStamp = function() return date end end + local req, err = codeartsSignedRequest(tostring(args.method or "GET"), tostring(args.url or ""), + { access_key_id = tostring(args.ak or ""), secret_access_key = tostring(args.sk or ""), + security_token = tostring(args.st or "") }, tostring(args.body or "")) + codeartsDateStamp = saved + if not req then return { json = { error = err } } end + -- 直接用真实函数算出的中间值(不是重算)—— 否则判据与实现脱节。 + return { json = { canonical = req.canonical, string_to_sign = req.string_to_sign, + signature = hmac_sha256_hex(tostring(args.sk or ""), req.string_to_sign), + signed_headers = req.signed_headers, + authorization = req.headers["Authorization"] } } +end +function plugin.__trae_derive_probe(args) + local uid = tostring((type(args) == "table" and args.uid) or "") + return { json = { devid = traeDeriveDeviceId15(uid), + market = traeDeriveMarketUserId(uid), + sess = traeDeriveSessionId(uid) } } end -- plugin.on_unload:插件被卸载/重载时回收自己拉起的浏览器栈。进程是插件起的, -- 收尾也是插件的责任 —— 内核不应知道这些进程的存在。 plugin.on_unload = function() reapBrowser() + -- 回调接收器也是本插件起的进程,卸载/重载时一并回收。 + -- 不能只收浏览器栈:否则每次插件重载都留下一批 python 监听。 + for id in pairs(plugin.state.loopbacks or {}) do + reapLoopback(id) + end end -- ============================================================================ @@ -4047,7 +8599,7 @@ plugin.ui = { page = { page_id = "channels", title = "账号接入", - icon = "🔌", + icon = "", order = 40, mount = [[
@@ -4089,6 +8641,8 @@ plugin.ui = { + +
@@ -4224,12 +8778,32 @@ plugin.ui = { }).join(""); } + window.chModelRefresh = async () => { + const id = state.sel && state.sel.id; + if (!id) return; + const r = await call("channels/model/refresh", "POST", { platform: id }); + const res = (r.results || [])[0] || {}; + if (res.ok) alert("已从上游拉取 " + res.count + " 个模型"); + else alert("拉取失败:" + (res.error || "未知错误") + "(保留既有目录)"); + await renderModels(id); + }; + async function renderModels(id) { const j = await call("channels/model/list", "POST", { platform: id }); const ms = j.models || []; state.models = ms; const on = ms.filter((m) => !m.disabled).length; q("#ch-model-count").textContent = on + "/" + ms.length + " 启用"; + // 目录来源:远端拉到的目录以远端为准、兜底补位;没拉过则只有兜底。 + const src = q("#ch-model-src"); + if (src) { + if (j.remote) { + const t = j.fetched_at ? new Date(j.fetched_at * 1000).toLocaleString() : ""; + src.textContent = "上游目录(" + t + ")"; + } else { + src.textContent = "内置兜底目录(未从上游拉取)"; + } + } q("#ch-models").innerHTML = ms.map((m) => '
' + '' + esc(m.name || m.id) + '' + @@ -4395,4 +8969,384 @@ plugin.ui = { }, } +-- plugin.__store_dir_probe:只给测试用 —— 报告本插件实际使用的存储目录。 +-- 测试污染的排查完全依赖它:storeDir() 有多级兜底(env → settings → 读生产 +-- config.yaml → 硬编码 /etc/llmsproxy/plugins),写错一级就会让测试**写进生产**。 +function plugin.__store_dir_probe() + return { json = { store_dir = storeDir() } } +end + +-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。 +-- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则 +-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。 +function plugin.credits_tier_probe(args) + args = args or {} + local provider = tostring(args.platform or "") + local accountId = tostring(args.account_id or "") + local raw = accountBalanceRaw(provider, accountId) + if raw == nil then return { json = { error = "余额不可用" } } end + local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits + local allowPermanent = not lockPermanent + local tier = balanceTier(raw.expiring_balance, raw.permanent_balance, allowPermanent) + return { json = { + expiring = raw.expiring_balance or 0, + permanent = raw.permanent_balance or 0, + tier = tier, + usable = tierUsable(tier), + locked = lockPermanent and true or false, + window_days = expiringWindowDays(), + } } +end + +-- plugin.__set_lock_permanent_probe:只给测试用 —— 设置"锁定永久积分"开关。 +function plugin.__set_lock_permanent_probe(args) + plugin.state.settings = plugin.state.settings or {} + plugin.state.settings.lock_permanent_credits = (args or {}).lock and true or false + return { json = { ok = true, locked = plugin.state.settings.lock_permanent_credits } } +end + +-- plugin.__set_expiring_window_probe:只给测试用 —— 覆盖"临时积分"窗口天数。 +-- 用环境变量实现(与生产同一路径,避免测试走旁路而与真实行为脱节)。 +function plugin.__set_expiring_window_probe(args) + local d = tonumber((args or {}).days) + if d == nil then return { json = { error = "缺 days" } } end + plugin.state.settings = plugin.state.settings or {} + plugin.state.settings.expiring_window_days = d + -- 窗口变了必须让余额缓存失效:缓存的是**原料**(包列表),而分桶是每次现算的, + -- 但原料里的"有效/失效"判定也依赖窗口吗?不依赖 —— 依赖窗口的只有分桶。 + -- 清缓存是为了让判据能立即看到新窗口的效果(生产里 60s 后自然过期)。 + ttlCacheInvalidate("balance") + return { json = { ok = true, days = expiringWindowDays() } } +end + +-- plugin.__set_legacy_cred_probe:只给测试用 —— 造一条"升级前"的非池凭据。 +function plugin.__set_legacy_cred_probe(args) + args = args or {} + local ref = "channels:" .. tostring(args.platform or "") + __llmsproxy_secrets.set(ref, tostring(args.credential or "")) + return { json = { ok = true, ref = ref } } +end + +-- plugin.__read_cred_probe:只给测试用 —— 读一条凭据(保险库解密后的**明文**)。 +-- 判据靠它证明"接管没有把凭据二次加密"。 +function plugin.__read_cred_probe(args) + args = args or {} + local v = __llmsproxy_secrets.get(tostring(args.ref or "")) + return { json = { credential = v, len = v and #v or 0 } } +end + +-- plugin.__clear_all_creds_probe:只给测试用 —— 清掉该插件的全部凭据。 +function plugin.__clear_all_creds_probe() + local ks = __llmsproxy_secrets.keys and __llmsproxy_secrets.keys() or {} + for _, k in ipairs(ks) do __llmsproxy_secrets.delete(k) end + return { json = { ok = true } } +end + +-- 以下四个探针只给测试用:把通用件(TTL 缓存 / 串行锁)的语义逐条钉住。 +-- 边界条件(清缓存要连带清在飞标记、ttl=0 不该命中、锁要能自解)都是踩过的坑。 +plugin.state.ttlProbeLoads = plugin.state.ttlProbeLoads or {} + +function plugin.__ttl_reset_probe() + plugin.state.ttlProbeLoads = {} + plugin.state.ttlCache = {} + plugin.state.ttlInflight = {} + plugin.state.serialLock = {} + return { json = { ok = true } } +end + +function plugin.__ttl_probe(args) + args = args or {} + local ns, k = tostring(args.ns or ""), tostring(args.key or "") + local ttl = tonumber(args.ttl) or 60 + local lk = ns .. ":" .. k + local v = ttlCacheGet(ns, k, ttl, function() + plugin.state.ttlProbeLoads[lk] = (plugin.state.ttlProbeLoads[lk] or 0) + 1 + return "v" .. tostring(plugin.state.ttlProbeLoads[lk]) + end) + return { json = { value = v, loads = plugin.state.ttlProbeLoads[lk] or 0 } } +end + +function plugin.__ttl_invalidate_probe(args) + args = args or {} + ttlCacheInvalidate(tostring(args.ns or ""), args.key and tostring(args.key) or nil) + return { json = { ok = true } } +end + +function plugin.__serial_probe(args) + args = args or {} + local ok = serialTryAcquire(tostring(args.ns or ""), tonumber(args.ttl) or 60) + return { json = { acquired = ok } } +end + +function plugin.__serial_release_probe(args) + args = args or {} + serialRelease(tostring(args.ns or "")) + return { json = { ok = true } } +end + +-- plugin.__parse_capability_probe:只给测试用 —— 把一段 models.dev 响应交给 +-- 真实的解析逻辑,回报统计。用它验证字段口径对得上真实数据(5.6MB/229 键)。 +function plugin.__parse_capability_probe(args) + args = args or {} + local ok, rec = pcall(json.decode, tostring(args.body or "")) + if not ok or type(rec) ~= "table" then return { json = { error = "解析失败" } } end + local key = tostring(args.key or "opencode") + local src = rec[key] + if type(src) ~= "table" then return { json = { error = "无该键" } } end + local models = type(src.models) == "table" and src.models or src + local total, free, image, reasoning, with_ctx = 0, 0, 0, 0, 0 + local sample = nil + for id, raw in pairs(models) do + local cap = normalizeCapability(tostring(id), raw) + if cap then + total = total + 1 + if cap.isFree then free = free + 1 end + for _, m in ipairs(cap.modalities) do + if m == "image" then image = image + 1 break end + end + if cap.reasoning then reasoning = reasoning + 1 end + if (cap.contextWindow or 0) > 0 then with_ctx = with_ctx + 1 end + if not sample then sample = { id = id, isFree = cap.isFree, + modalities = cap.modalities, contextWindow = cap.contextWindow } end + end + end + return { json = { total = total, free = free, image = image, + reasoning = reasoning, with_ctx = with_ctx, sample = sample } } +end + +-- plugin.__inject_refresh_platform_probe:只给测试用 —— 造一个带续期声明的平台 +-- 与一条账号。**夹具绝不写进生产源码**(此前 example/googleish/loopbackish 这类 +-- 测试平台被写进生产目录的事故就是教训)。 +function plugin.__inject_refresh_platform_probe(args) + args = args or {} + local id = tostring(args.id or "") + -- ⚠️ injected_platforms 是**数组**(platformById 用 ipairs 遍历),不是映射。 + plugin.state.injected_platforms = plugin.state.injected_platforms or {} + local lst = plugin.state.injected_platforms + local repl = nil + for i, p in ipairs(lst) do if type(p) == "table" and p.id == id then repl = i break end end + local entry = { + id = id, + label = id, + flow = "manual", + adapter = "openai", + base_url = "http://127.0.0.1:1/v1/chat/completions", + refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") }, + models = { "m1" }, + hosts = { "127.0.0.1" }, + } + if repl then lst[repl] = entry else lst[#lst + 1] = entry end + -- 建账号(凭据走保险库) + local ref = "channels:" .. id + __llmsproxy_secrets.set(ref, tostring(args.credential or "{}")) + poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = 0 }) + return { json = { ok = true } } +end + +-- plugin.__clear_capabilities_probe:只给测试用 —— 清能力缓存(模拟冷启动)。 +function plugin.__clear_capabilities_probe() + plugin.state.capabilities = nil + plugin.state.capabilitiesMeta = nil + return { json = { ok = true } } +end + +-- plugin.__inject_capability_probe:只给测试用 —— 注入一条能力元数据。 +function plugin.__inject_capability_probe(args) + args = args or {} + local plat = tostring(args.platform or "") + plugin.state.capabilities = plugin.state.capabilities or {} + plugin.state.capabilities[plat] = plugin.state.capabilities[plat] or {} + plugin.state.capabilities[plat][tostring(args.model_id or "")] = { + id = tostring(args.model_id or ""), + name = tostring(args.model_id or ""), + isFree = args.is_free and true or false, + modalities = { "text" }, + contextWindow = 100000, maxOutputTokens = 8000, + reasoning = false, efforts = {}, toolCall = true, + } + return { json = { ok = true } } +end + +-- plugin.__rank_accounts_probe:只给测试用 —— 暴露余额分档选号的结果。 +function plugin.__rank_accounts_probe(args) + args = args or {} + local r = rankAccounts(tostring(args.platform or "")) + local ids = {} + for _, a in ipairs(r.all or {}) do ids[#ids+1] = a.id end + return { json = { expiring = r.expiring, permanent = r.permanent, + none = r.none, locked = r.locked, all = ids, + usable_n = #(r.usable or {}) } } +end + +-- plugin.__expiring_window_probe:只给测试用 —— 读当前生效的窗口天数。 +function plugin.__expiring_window_probe() + return { json = { days = expiringWindowDays() } } +end + +-- plugin.__is_model_gone_probe:只给测试用 —— 暴露失效判据本体。 +-- 判据必须能被逐条钉住:这些模式的误判代价是把**可用模型藏 30 天**。 +function plugin.__is_model_gone_probe(args) + return { json = { gone = isModelGone(tostring((args or {}).message or "")) } } +end + +-- plugin.__record_dead_probe:只给测试用 —— 记录一条失效证据。 +function plugin.__record_dead_probe(args) + args = args or {} + local isnew = recordDeadModel(tostring(args.platform or ""), tostring(args.model_id or ""), + tostring(args.reason or "")) + return { json = { recorded = isnew } } +end + +-- plugin.__catalog_gate_probe:只给测试用 —— 读闸门状态并尝试进入一次。 +function plugin.__catalog_gate_probe(args) + args = args or {} + local now = os.time and os.time() or 0 + local cooling = catalogGateCooling(tostring(args.platform or ""), now) + local began = false + if not cooling then began = catalogGateBegin(tostring(args.platform or ""), now) end + return { json = { began = began, cooling = cooling } } +end + +-- plugin.__catalog_gate_fail_probe:只给测试用 —— 模拟一次拉取失败(进冷却)。 +function plugin.__catalog_gate_fail_probe(args) + args = args or {} + local now = os.time and os.time() or 0 + local p = tostring(args.platform or "") + catalogGateBegin(p, now) + catalogGateEnd(p, false, now) + return { json = { ok = true } } +end + +-- plugin.credits_capability:只给测试用 —— 暴露能力表条目,让判据能断言 +-- "表与参考真值一致"(表错了只会静默少领,没有任何报错)。 +function plugin.credits_capability(args) + args = args or {} + local cap = CHANNEL_CREDITS[args.platform] + if not cap then return { json = { error = "无能力声明" } } end + local out = {} + for k, v in pairs(cap) do + if type(v) ~= "function" then out[k] = v end + end + return { json = { capability = out } } +end + +-- plugin.claims_capable:只给测试用 —— 判断某平台**真的**能不能领 +--(有无 checkin 表或 claim_fn)。这与"能力表声称能领"是两件事, +-- 两者不一致就会出现自相矛盾的文案。 +function plugin.claims_capable(args) + args = args or {} + local plat = platformById(args.platform) + if not plat then return { json = { error = "未知平台" } } end + local has_checkin = plat.checkin ~= nil and plat.checkin.url ~= nil + local has_fn = plat.claim_fn ~= nil + local cap = CHANNEL_CREDITS[plat.id] or {} + return { json = { + capable = (has_checkin or has_fn), + has_checkin = has_checkin, + has_claim_fn = has_fn, + table_says_claim = cap.claim == true, + auto_exclude = cap.auto_exclude == true, + reason = (not (has_checkin or has_fn)) and + ("既无 checkin 表也无 claim_fn(claim_fn=" .. tostring(plat.claim_fn ~= nil) .. ")") or "", + } } +end + +-- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的 +-- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。 +function plugin.__adapter_install_probe() + return { json = { + codearts = plugin._codearts_adapter, + trae = plugin._trae_adapter, + gemini = plugin._gemini_adapter, + } } +end + +-- plugin.__sync_source_probe:只给测试/诊断用 —— 手动触发一次源同步并 +-- 返回内核 add_source 的原始结果。把"静默不出源"变成可观测的结果。 +function plugin.__sync_source_probe(args) + args = args or {} + local plat = platformById(args.platform) + if not plat then return { json = { error = "未知平台" } } end + local acct = poolGet(args.platform, args.account_id) + if not acct then return { json = { error = "账号不存在" } } end + local raw = __llmsproxy_secrets.get(acct.credentialRef) + local tok = nil + if plat.credential_shape == "full" then tok = raw else tok = credentialOf(acct.credentialRef) end + syncAccountSource(args.platform, args.account_id) + -- 回报同步后内核里该源是否存在 + local ls = __llmsproxy_kernel.call("list_sources", {}) + local found, ad = false, nil + local want = accountSourceName(args.platform, args.account_id) + local arr = (ls and ls.json and ls.json.sources) or (ls and ls.sources) or {} + for _, x in ipairs(arr) do + if x.name == want then found = true ad = x.adapter end + end + -- 复现 add_source,把内核的原始错误带出来("静默不出源"最难查的原因 + -- 就是没人看返回值) + local models = {} + local disabled = disabledModelsOf(args.platform) + for _, m in ipairs(effectiveModels(args.platform)) do + if not disabled[m] then models[#models+1] = { id = m, kind = "chat", priority = 20 } end + end + local test_src = { + name = want, base_url = plat.base_url, adapter = plat.adapter or "openai", + api_key = tok, max_concurrent = 1, models = models, + } + if plat.endpoint then test_src.endpoint = plat.endpoint end + local rr, rerr = __llmsproxy_kernel.call("add_source", { source = test_src }) + return { json = { credential_shape = plat.credential_shape or "token", + tok_len = tok and #tok or -1, + source_name = want, found = found, adapter = ad, + adapter_decl = plat.adapter, + add_ok = rr and rr.json and rr.json.ok or false, + add_err = tostring(rerr), + add_json = rr and json.encode(rr.json or {}) or "nil", + models_n = #models } } +end + +-- 绑定 zcode 的领取钩子(同 raccoon/loomy:local 前向引用,必须放末尾)。 +do + for _, p in ipairs(PLATFORMS) do + if p.id == "zcode" then + p.claim_fn = function(plat, tok, args) return claimZcode(plat, tok, args) end + break + end + end +end + +-- 绑定 loomy 的领取钩子(同 raccoon:local 前向引用,必须放末尾)。 +do + for _, p in ipairs(PLATFORMS) do + if p.id == "loomy" then + p.claim_fn = function(plat, tok, args) return claimLoomy(plat, tok, args) end + break + end + end +end + +-- 绑定 raccoon 的领取钩子。必须放在**文件末尾**:claimRaccoon 是 local, +-- 只能从定义处向后可见(本文件已因 local 前向引用踩过 8 次)。 +do + for _, p in ipairs(PLATFORMS) do + if p.id == "raccoon" then + p.claim_fn = function(plat, tok, args) return claimRaccoon(plat, tok, args) end + break + end + end +end + +-- 装本插件自己的专用适配器。必须放在**文件末尾**(installCodeartsAdapter 是 +-- local,只能从定义处向后可见 —— 本文件已因 local 前向引用踩过 7 次)。 +-- 放在脚本执行末尾即可:适配器装载与源注册都发生在脚本执行完之后。 +do + local ok, err = installCodeartsAdapter() + plugin._codearts_adapter = { ok = ok, err = err } + local tok2, terr2 = installTraeAdapter() + plugin._trae_adapter = { ok = tok2, err = terr2 } + local gok, gerr = installGeminiAdapter() + plugin._gemini_adapter = { ok = gok, err = gerr } + if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end + if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end +end + return plugin diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 796d1dc..912cc56 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -1177,7 +1177,7 @@ PLUGIN.ui = { '" style="width:'+Math.min(100,p)+'%">
'+ '
$'+used.toFixed(2)+' / $'+cap.toFixed(0)+'
'; if(r.cap_mismatch){ - h+='
⚠ 额度口径可能有变
'; + h+='
注意 额度口径可能有变
'; } return h+""; } diff --git a/internal/lua/plugins/usage.lua b/internal/lua/plugins/usage.lua index 6193004..54e12c5 100644 --- a/internal/lua/plugins/usage.lua +++ b/internal/lua/plugins/usage.lua @@ -20,7 +20,7 @@ -- 取自己的 /api/stats —— 它已按调用 key 隔离(实测:user key 231 次 = 自己的, -- admin 33847 次 = 全量)。 -- --- ★ 必须传**掩码**,不能传完整 key。 +-- 关键:必须传**掩码**,不能传完整 key。 -- /api/stats 的统计 map 以 keyID(req.Key)(即 "***f9a9ed")为键 —— by_key -- 返回的就是它,面板传的也是它。而 admin 身份下 ?key= 接受任意值:传完整 key -- 会查不到,拿到零值。实测: diff --git a/internal/lua/plugins_test.go b/internal/lua/plugins_test.go index 7fdc0f4..24359ab 100644 --- a/internal/lua/plugins_test.go +++ b/internal/lua/plugins_test.go @@ -220,7 +220,7 @@ p.ui = { page = { page_id = "billing", title = "Billing", - icon = "💰", + icon = "", order = 50, mount = "
hi
", }, diff --git a/internal/lua/schedule_first_due_test.go b/internal/lua/schedule_first_due_test.go new file mode 100644 index 0000000..c9927ec --- /dev/null +++ b/internal/lua/schedule_first_due_test.go @@ -0,0 +1,104 @@ +package lua + +import ( + "testing" + "time" +) + +// 「启动即跑一轮」判据。 +// +// 为什么需要它(照参考的事后复盘):只用 interval 时,宿主重启后短寿命凭据 +// (cline 1h / codearts ~2h / raccoon 3h)要等满一个周期才续期,期间一直显示 +// 「已过期」、积分行 401。修复 = 声明 first_due_now 的任务在启动后短暂延迟内 +// 先跑一轮。 +// +// 但它**必须 opt-in**:真正周期性的任务(如每日签到)不该因为重启就重跑。 +func TestScheduleFirstDueNow(t *testing.T) { + // ① 声明了 first_due_now 的任务:首轮应在 catchUpDelay 内,而不是等一个周期 + now := time.Now() + catchUp := ScheduledTask{Fn: "f", Every: 7200, FirstDueNow: true} + rt := &PluginRuntime{} + d := rt.firstDue(catchUp) + if d.After(now.Add(catchUpDelay + time.Second)) { + t.Errorf("first_due_now 的任务首轮应在 %v 内,实得 %v 后", + catchUpDelay, d.Sub(now)) + } + if d.Before(now) { + t.Errorf("首轮时间不该在过去:%v", d.Sub(now)) + } + + // ② 没声明的:必须等满一个周期(否则每日/每小时的语义会被破坏) + plain := ScheduledTask{Fn: "f", Every: 7200} + d2 := rt.firstDue(plain) + if d2.Sub(now) < 7000*time.Second { + t.Errorf("未声明 first_due_now 的任务不该提前跑:实得 %v", d2.Sub(now)) + } + + // ③ at 形式(每日固定时刻)不受标记影响 —— 它本来就该等到那个时刻 + daily := ScheduledTask{Fn: "f", At: "08:00"} + d3 := rt.firstDue(daily) + if d3.Sub(now) > 25*time.Hour { + t.Errorf("每日任务的首次时间超出一天:%v", d3.Sub(now)) + } + if d3.Sub(now) < catchUpDelay && !daily.FirstDueNow { + // 只有当 08:00 恰好在 catchUpDelay 内才可能,属正常 + t.Logf("每日任务首轮临近(可能恰好在 08:00 附近):%v", d3.Sub(now)) + } + + // ④ 声明了 first_due_now 的每日任务:优先补跑(凭据不等时刻) + dailyCatchUp := ScheduledTask{Fn: "f", At: "08:00", FirstDueNow: true} + d4 := rt.firstDue(dailyCatchUp) + if d4.Sub(now) > catchUpDelay+time.Second { + t.Errorf("声明 first_due_now 时即便有 at 也应先补跑:%v", d4.Sub(now)) + } +} + +// 解析层判据:first_due_now 必须真的从插件源码读出来(写错键名会静默变成 +// false —— 那就是"看起来配了、实际没补跑")。 +func TestScheduleParsesFirstDueNow(t *testing.T) { + const src = ` +local plugin = { name = "schedprobe", version = "1.0" } +plugin.exports = { "noop" } +function plugin.noop() end +function plugin.refresh_creds() end +function plugin.auto_claim() end +plugin.schedule = { + { fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 }, + { fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 }, +} +return plugin +` + _, ps, _ := runtimePlugins(t) + if err := ps.LoadSource("schedprobe", src); err != nil { + t.Fatal(err) + } + ps.mu.RLock() + var sched []ScheduledTask + for _, p := range ps.plugins { + if p.Info.Name == "schedprobe" { + sched = p.Schedule + } + } + ps.mu.RUnlock() + if len(sched) != 2 { + t.Fatalf("应解析出 2 个任务,实得 %d: %+v", len(sched), sched) + } + byFn := map[string]ScheduledTask{} + for _, task := range sched { + byFn[task.Fn] = task + } + if task, ok := byFn["refresh_creds"]; !ok { + t.Fatal("refresh_creds 任务未解析") + } else if !task.FirstDueNow { + t.Error("first_due_now 未被解析为 true(键名写错会静默失效)") + } else if task.Every != 1800 { + t.Errorf("every = %d,应为 1800", task.Every) + } + if task, ok := byFn["auto_claim"]; !ok { + t.Fatal("auto_claim 任务未解析") + } else if task.FirstDueNow { + t.Error("auto_claim 未声明 first_due_now,应为 false(每日任务不该因重启重跑)") + } else if task.At != "08:00" { + t.Errorf("at = %q,应为 08:00", task.At) + } +} diff --git a/internal/lua/secrets_crypto_test.go b/internal/lua/secrets_crypto_test.go index 28fe838..5baea0a 100644 --- a/internal/lua/secrets_crypto_test.go +++ b/internal/lua/secrets_crypto_test.go @@ -98,7 +98,7 @@ func TestSecretVaultEncryptedAtRest(t *testing.T) { t.Fatal(err) } if strings.Contains(string(raw), "SUPER-TOKEN-123") { - t.Fatalf("★ 保险库明文落盘! 文件内容含 token:\n%s", raw) + t.Fatalf("保险库明文落盘! 文件内容含 token:\n%s", raw) } if !strings.Contains(string(raw), testEncPrefix) { t.Fatalf("落盘值应带 %s 前缀(统一密文形态):\n%s", testEncPrefix, raw) @@ -140,7 +140,7 @@ func TestSecretVaultPlaintextFallbackAndUpgrade(t *testing.T) { } raw, _ := os.ReadFile(filepath.Join(dir, ".secrets", "channels.json")) if strings.Contains(string(raw), "OLD-PLAIN") { - t.Fatalf("★ 升级写后旧明文值仍在盘上:\n%s", raw) + t.Fatalf("升级写后旧明文值仍在盘上:\n%s", raw) } var m map[string]string if err := json.Unmarshal(raw, &m); err != nil { @@ -169,7 +169,7 @@ func TestSecretVaultWrongMasterKey(t *testing.T) { // 换一把钥匙(模拟 master.key 丢失重建) ps.SetSecretBox(failBox{}) if _, _, err := ps.GetSecret("channels", "k"); err == nil { - t.Fatal("★ 换 master key 后读库未报错 —— 会静默吞凭据") + t.Fatal("换 master key 后读库未报错 —— 会静默吞凭据") } } @@ -205,7 +205,7 @@ func TestSecretVaultConcurrentPlugins(t *testing.T) { // 跨插件必须不可见 other := "plug" + string(rune('A'+(p+1)%plugins)) if _, ok, _ := ps.GetSecret(other, key); ok { - t.Errorf("★ 凭据串台: %s 的键在 %s 可见", name, other) + t.Errorf("凭据串台: %s 的键在 %s 可见", name, other) return } if g == 1 && i%10 == 9 { diff --git a/internal/lua/store_dir_isolation_test.go b/internal/lua/store_dir_isolation_test.go new file mode 100644 index 0000000..69b4d21 --- /dev/null +++ b/internal/lua/store_dir_isolation_test.go @@ -0,0 +1,61 @@ +package lua + +import ( + "os" + "strings" + "testing" +) + +// **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。 +// +// 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底: +// 环境变量 → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** → +// 硬编码 /etc/llmsproxy/plugins。测试台没设环境变量时,后两级直接命中**生产**: +// +// 测试插件目录 = /tmp/TestXXX/002/plugins +// storeDir 结果 = /etc/llmsproxy/plugins ← 实测 +// +// 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量 +// `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool: +// /etc/llmsproxy/plugins/.channels-store.json 从 1730 变 1986 字节, +// 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。 +// +// 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产 +// .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。 +func TestStoreDirIsolatedInTests(t *testing.T) { + _, ps, pdir := runtimePlugins(t) + src, err := bundledAdapters.ReadFile("plugins/channels.lua") + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", string(src)); err != nil { + t.Fatal(err) + } + out, err := ps.Call("channels", "__store_dir_probe", nil) + if err != nil { + t.Fatalf("探针: %v", err) + } + w, _ := out.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j == nil { + t.Fatalf("探针无 json: %#v", out) + } + got, _ := j["store_dir"].(string) + t.Logf("测试插件目录 = %s", pdir) + t.Logf("storeDir 结果 = %s", got) + + // ① 绝不能是生产目录 + if got == "/etc/llmsproxy/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") { + t.Fatalf("storeDir 指向了**生产**目录 %q —— 测试会覆盖生产账号池\n"+ + "(真实事故:13 个账号被覆盖成 0 个)。修法:runtimePlugins 设\n"+ + "LLMSPROXY_PLUGIN_DIR 到 t.TempDir()", got) + } + // ② 必须落在测试的临时目录里 + if !strings.Contains(got, pdir) && !strings.HasPrefix(got, os.TempDir()) { + t.Errorf("storeDir 应落在临时目录(%s 或 %s),实得 %q", pdir, os.TempDir(), got) + } + // ③ 环境变量必须真的被设上(机制而非巧合) + if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" { + t.Error("runtimePlugins 必须设 LLMSPROXY_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)") + } +} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index f8ca65c..3dfb7fe 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -873,6 +873,12 @@ func (v *VM) LoadAdapterSource(name, code string) error { if err := ValidateAdapterName(name, nil); err != nil { return err } + // 共享前导注入点 —— 只此一处。适配器源码有三个编译点 + //(worker boot / HasFn 探针 / inspectScript),它们全部源自这里存进 + // pool 的 script 文本,所以拼在这里就同时覆盖三条路径,且不改变 Lua + // 作用域语义(运行时 SetGlobal 会在热重载/池重建时丢失)。 + // 见 adapter_prelude.go 的开篇:12 个适配器里有 12680 字节的逐字重复。 + code = withAdapterPrelude(code) static, err := inspectScript(name, code) if err != nil { return err diff --git a/internal/scheduler/empty_response_test.go b/internal/scheduler/empty_response_test.go index b86369d..8fc90c9 100644 --- a/internal/scheduler/empty_response_test.go +++ b/internal/scheduler/empty_response_test.go @@ -32,7 +32,7 @@ func TestResultIsEmptyMatchesTheReasoningOnlyResponse(t *testing.T) { } // The same response WITH text is a normal answer. - if resultIsEmpty(&types.UnifiedResponse{Content: "Hi! 👋", ReasoningContent: "hmm"}) { + if resultIsEmpty(&types.UnifiedResponse{Content: "Hi! ", ReasoningContent: "hmm"}) { t.Error("a response with content is not empty") } // A tool-calling agent turn has no text and is perfectly valid. diff --git a/packaging/config.example.yaml b/packaging/config.example.yaml index f1ef9c6..ad9441b 100644 --- a/packaging/config.example.yaml +++ b/packaging/config.example.yaml @@ -3,7 +3,7 @@ # 这个是打包进发行包、供首次安装使用的模板。它故意不含任何可用的凭据: # 首次启动时程序会生成一个随机 admin key 并写入实际配置(WebUI「密钥」页可见)。 # -# ⚠ 不要在本文件里填一个写死的具体 key。示例文件是公开的,写进去的任何值 +# 注意: 不要在本文件里填一个写死的具体 key。示例文件是公开的,写进去的任何值 # 都会成为一个全网皆知的 admin key(旧版示例里的 sk-gw-local-0001 就踩过 # 这个坑:它被直接抄进生产配置并真实可用)。 diff --git a/packaging/core-dist.sh b/packaging/core-dist.sh index 3013480..5b4e26d 100755 --- a/packaging/core-dist.sh +++ b/packaging/core-dist.sh @@ -103,7 +103,7 @@ for f in "$DIST"/llmsproxy_*.deb "$DIST"/llmsproxy-*.rpm "$DIST"/*.tar.gz; do [ -f "$f" ] || continue sz=$(stat -c%s "$f") [ "$sz" -gt 100000 ] || { echo "[core-dist] FATAL: $f suspiciously small ($sz B)"; exit 1; } - log " ✓ $f ($((sz/1024)) KB)" + log " OK $f ($((sz/1024)) KB)" done # dpkg-deb takes ONE archive; every extra argument is read as an additional # control-component name. With the glob unquoted this used to expand to three @@ -133,7 +133,7 @@ for want in llmsproxy config.example.yaml llmsproxy.service plugins/billing.lua; echo " (release notes claim the billing plugin ships with the core)" exit 1 fi - log " ✓ archive contains $want" + log " OK archive contains $want" done # Directories are matched by their CONTENTS, not by name: tar written with # `tar -czf` from a populated tree lists "…/adapters/openai.lua" and never a bare @@ -144,6 +144,6 @@ if [ "$n_adapters" -lt 10 ]; then echo "[core-dist] FATAL: archive has only $n_adapters adapters (expected >= 10)" exit 1 fi -log " ✓ archive contains $n_adapters adapters" +log " OK archive contains $n_adapters adapters" log "done: $DIST" \ No newline at end of file diff --git a/packaging/install-browser-stack.sh b/packaging/install-browser-stack.sh index 7026a0a..601b0f5 100755 --- a/packaging/install-browser-stack.sh +++ b/packaging/install-browser-stack.sh @@ -53,7 +53,7 @@ if command -v python3 >/dev/null 2>&1; then fi if [ ${#missing[@]} -eq 0 ]; then - echo "✓ 浏览器栈依赖齐全" + echo "OK 浏览器栈依赖齐全" echo " chromium: ${chromium}" echo " noVNC: ${novnc}" exit 0 @@ -98,24 +98,24 @@ echo echo "复核安装结果:" ok=1 for b in "${BINS[@]}"; do - if command -v "$b" >/dev/null 2>&1; then echo " ✓ $b" - else echo " ✗ $b"; ok=0; fi + if command -v "$b" >/dev/null 2>&1; then echo " OK $b" + else echo " FAIL $b"; ok=0; fi done chromium="" for c in "${CHROMIUM_CANDIDATES[@]}"; do if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi done -[ -n "$chromium" ] && echo " ✓ $chromium" || { echo " ✗ chromium"; ok=0; } +[ -n "$chromium" ] && echo " OK $chromium" || { echo " FAIL chromium"; ok=0; } novnc="" for d in "${NOVNC_DIRS[@]}"; do [ -f "$d/vnc.html" ] && { novnc="$d"; break; } done -[ -n "$novnc" ] && echo " ✓ noVNC ($novnc)" || { echo " ✗ noVNC"; ok=0; } -python3 -c 'import websocket' >/dev/null 2>&1 && echo " ✓ python3 websocket-client" || { echo " ✗ python3 websocket-client"; ok=0; } +[ -n "$novnc" ] && echo " OK noVNC ($novnc)" || { echo " FAIL noVNC"; ok=0; } +python3 -c 'import websocket' >/dev/null 2>&1 && echo " OK python3 websocket-client" || { echo " FAIL python3 websocket-client"; ok=0; } if [ "$ok" = 1 ]; then echo - echo "✓ 浏览器栈就绪。重启 llmsproxy 后,flow=vnc 的平台即可用 VNC 兜底登录。" + echo "OK 浏览器栈就绪。重启 llmsproxy 后,flow=vnc 的平台即可用 VNC 兜底登录。" else echo echo "WARN: 仍有缺失项,VNC 兜底在该机器上不可用。插件的 deps/status 端点会一样报告。" >&2 diff --git a/packaging/upload-release.sh b/packaging/upload-release.sh index 1895789..413b97b 100755 --- a/packaging/upload-release.sh +++ b/packaging/upload-release.sh @@ -20,7 +20,7 @@ set -uo pipefail TOKEN="$(tr -d '\n\r' < /tmp/gc_token)" -REPO="JianFeeeee/ModelRouter" +REPO="HeNanMU-EEA/ModelRouter" TAG="${1:?usage: upload-release.sh ...}" shift @@ -43,7 +43,7 @@ try: except Exception: print("")' 2>/dev/null)" if [ -z "$url" ]; then - echo "✗ 取得上传地址失败: ${resp:0:140}" + echo "FAIL 取得上传地址失败: ${resp:0:140}" continue fi @@ -63,9 +63,9 @@ print(subprocess.run(args, capture_output=True, text=True).stdout) ' "$f" "$url" 2>/dev/null)" if [ "$code" = "200" ] || [ "$code" = "201" ] || [ "$code" = "204" ]; then - echo "✓ HTTP=$code" + echo "OK HTTP=$code" else - echo "✗ HTTP=$code" + echo "FAIL HTTP=$code" fi sleep 1 done diff --git a/packaging/verify-dist.sh b/packaging/verify-dist.sh index 9e1ef8b..a90796a 100755 --- a/packaging/verify-dist.sh +++ b/packaging/verify-dist.sh @@ -43,7 +43,7 @@ for entry in "${checks[@]}"; do if [ "$sz" -lt "$min" ]; then fail "$(basename "$match") is only $sz bytes (min $min) — likely a degenerate shell like the 1.3.0 264KB exe" fi - log " ✓ $(basename "$match") ($((sz/1024)) KB)" + log " OK $(basename "$match") ($((sz/1024)) KB)" done log "all artifacts healthy" diff --git a/tools/codearts_sig_expected.mjs b/tools/codearts_sig_expected.mjs new file mode 100644 index 0000000..80f0353 --- /dev/null +++ b/tools/codearts_sig_expected.mjs @@ -0,0 +1,113 @@ +// 生成 CodeArts 华为云签名派生的差分期望值(供 internal/core/codearts_sign_test.go 比对)。 +// +// 为什么需要:channels.lua 里的华为云 SDK-HMAC-SHA256 签名是**手工移植** +// (Lua 没有 webcrypto)。签名错一处,服务端只回一句 +// `{"error_code":"APIG.0301","error_msg":"...verify ak sk signature fail"}`, +// 看起来像"账号/权限问题",极难定位。所以拿参考实现生成期望值做逐字节差分。 +// +// 参考真值:vendor/channel-pack/src/sign.ts 的 signRequestHuawei。 +// 这里**逐行照抄**那个函数(而非 import)—— 它依赖参考仓库的构建链, +// 抄这 40 行更能保证"同一算法"。 +// +// 用法(改了参考或改了移植后都要重跑): +// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json + +import { createHash, createHmac } from 'node:crypto' + +const sha256Hex = (d) => createHash('sha256').update(d).digest('hex') +const hmacSha256Hex = (key, data) => createHmac('sha256', key).update(data).digest('hex') + +/** 照抄 sign.ts:signRequestHuawei(dateStamp 提为参数以便固定时间)。 */ +function signRequestHuawei(ak, sk, securityToken, method, urlStr, body, dateStamp) { + const url = new URL(urlStr) + let uri = url.pathname + if (!uri.endsWith('/')) uri += '/' + const query = url.search.slice(1) + const payloadHash = sha256Hex(body) + + const headers = new Map() + headers.set('host', url.host) + headers.set('x-sdk-date', dateStamp) + headers.set('x-sdk-content-sha256', payloadHash) + headers.set('x-security-token', securityToken) + if (method.toUpperCase() !== 'GET') headers.set('content-type', 'application/json') + + const signedHeaders = [] + headers.forEach((_, k) => signedHeaders.push(k)) + signedHeaders.sort() + const headerLines = signedHeaders.map((k) => `${k}:${headers.get(k) ?? ''}`) + const canonical = [ + method, + uri, + query, + headerLines.join('\n'), + '', + signedHeaders.join(';'), + payloadHash, + ].join('\n') + const stringToSign = `SDK-HMAC-SHA256\n${dateStamp}\n${sha256Hex(new TextEncoder().encode(canonical))}` + const signature = hmacSha256Hex( + new TextEncoder().encode(sk), + new TextEncoder().encode(stringToSign), + ) + return { + canonical, + stringToSign, + signature, + signedHeaders: signedHeaders.join(';'), + } +} + +// 覆盖三条真实路径(账户查询 / 活动列表带 query / 领取带 body), +// 以及有/无 security_token 两种形态。 +const cases = [ + { + m: 'GET', + u: 'https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin', + b: '', + ak: 'AK1', + sk: 'SK1', + st: 'TOK1', + }, + { + m: 'GET', + u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE', + b: '', + ak: 'AK2', + sk: 'SK2', + st: '', + }, + { + m: 'POST', + u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim', + b: '{"campaignId":"C1","channel":"IDE"}', + ak: 'AK3', + sk: 'SK3', + st: 'T3', + }, +] + +const out = cases.map((c) => { + const r = signRequestHuawei( + c.ak, + c.sk, + c.st, + c.m, + c.u, + Buffer.from(c.b, 'utf8'), + '20261013T150405Z', + ) + return { + method: c.m, + url: c.u, + body: c.b, + ak: c.ak, + sk: c.sk, + st: c.st, + canonical: r.canonical, + string_to_sign: r.stringToSign, + signature: r.signature, + signed_headers: r.signedHeaders, + } +}) +process.stdout.write(`${JSON.stringify(out, null, 1)}\n`) diff --git a/tools/trae_derive_expected.mjs b/tools/trae_derive_expected.mjs new file mode 100644 index 0000000..7f6108d --- /dev/null +++ b/tools/trae_derive_expected.mjs @@ -0,0 +1,74 @@ +// 生成 Trae 设备身份派生的差分期望值(供 internal/core/channels_trae_derive_test.go 比对)。 +// +// 为什么要有这个文件:channels.lua 里的 traeDeriveStream 是**手工移植**的 +// SHA-256 确定性伪随机流(Lua 5.1 没有 node:crypto)。移植错了不会报错 —— +// 上游只会返回风控/失败,看起来像"运气不好"。所以拿参考实现(参考仓库 +// vendor/channel-pack/src/trae.ts 的 seededStream/seededDigits)生成期望值, +// 再让 Lua 侧算同一组输入做差分。 +// +// 用法(改了参考或改了移植后都要重跑): +// node tools/trae_derive_expected.mjs > internal/core/trae_derive_expected.json +// +// 注意:这里刻意**逐行照抄**参考的 seededStream,而不是从参考仓库 import —— +// 参考是 TS/ESM 且依赖其构建链,直接抄这 20 行更能保证"同一算法"。 + +import { createHash } from 'node:crypto' + +/** 照抄 trae.ts:seededStream —— SHA256(utf8(salt:seed) ++ counterBE32) 串联。 */ +function seededStream(seed, salt, nbytes) { + const prefix = `${salt}:${seed}` + const result = [] + let counter = 0 + while (result.length < nbytes) { + const counterBuf = new Uint8Array(4) + counterBuf[0] = (counter >> 24) & 0xff + counterBuf[1] = (counter >> 16) & 0xff + counterBuf[2] = (counter >> 8) & 0xff + counterBuf[3] = counter & 0xff + const h = createHash('sha256') + h.update(prefix, 'utf8') + h.update(counterBuf) + for (const b of h.digest()) { + result.push(b) + if (result.length >= nbytes) break + } + counter++ + } + return result.slice(0, nbytes) +} + +/** 照抄 trae.ts:seededDigits —— 每字节 % 10,拼成 n 位数字串。 */ +function seededDigits(n, seed, salt) { + return seededStream(seed, salt, n).map((b) => b % 10).join('') +} + +/** 照抄 trae.ts:deriveDeviceId15。 */ +function deriveDeviceId15(userId) { + return seededDigits(15, userId, 'devid') +} + +/** 照抄 trae.ts:deriveMarketUserId —— UUID v4 形状。 */ +function deriveMarketUserId(userId) { + const bs = seededStream(userId, 'market', 16) + bs[6] = (bs[6] & 0x0f) | 0x40 + bs[8] = (bs[8] & 0x3f) | 0x80 + const hex = bs.map((b) => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` +} + +/** 照抄 trae.ts:deriveSessionId —— 64 位 hex。 */ +function deriveSessionId(userId) { + return seededStream(userId, 'sess', 32).map((b) => b.toString(16).padStart(2, '0')).join('') +} + +// 覆盖边界:纯数字 / 带连字符 / 空串(空串也要能算,不能崩)/ 多字节 UTF-8 / +// 超长(超过一个 SHA-256 块,逼出 counter 递增分支 —— 移植时最易错的地方)。 +const ids = ['12345', 'UID-abc', '900000001', 'x', '', '用户1', '1234567890123456789012345'] + +const out = ids.map((u) => ({ + u, + devid: deriveDeviceId15(u), + market: deriveMarketUserId(u), + sess: deriveSessionId(u), +})) +process.stdout.write(`${JSON.stringify(out, null, 1)}\n`)