From 8f2c5572e769c31d2542402643403409caeaecd7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 27 Oct 2026 09:23:24 +0800 Subject: [PATCH] =?UTF-8?q?chore!:=20=E7=A7=BB=E9=99=A4=E5=8E=82=E5=95=86?= =?UTF-8?q?=E7=A7=81=E6=9C=89=E5=8D=8F=E8=AE=AE=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E4=B8=8E=E6=B8=A0=E9=81=93=E4=B8=9A=E5=8A=A1=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E5=90=88=E8=A7=84=E6=B8=85=E7=90=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G-Star 合规要求下,把"模拟官方客户端、复刻私有协议、自动领取平台额度"这一类 能力整体移出仓库。这些实现依赖各平台未公开的端点、签名算法与官方 client id, 属于绕过客户端校验获取额度,风险高于普通协议适配。 移除(93 个文件): - 适配器:opencodezen(伪装 UA 取免费池)、trae、buddy、kimicode - 插件:channels.lua(12 个平台登录/领额度/签到)、quota.lua(厂商额度探针) - 随之删除的 78 个渠道/额度专用测试与 3 份渠道文档 同时修掉的既有问题: - **硬编码第三方 OAuth client_secret**(Google Cloud Code 的 GOCSPX-…,两处) 随 channels.lua 一起消失。 - `deploy.sh` 的 `sync_plugins` 整段删除:它仍按"内置插件是发行物"的旧前提 把插件复制/覆盖进 plugin_dir,与"插件属运维资产、网关不安装不覆盖"的契约 冲突。现在部署脚本只同步适配器与二进制/配置。 - 适配器清单去掉已移除项;e2e 配置改用显式 `auto:` 链(`models.priority` 早已 移除,该测试此前已失效)。 保留(公开协议):openai / anthropic / gemini / deepseek / github / groq / mistral / ollama / qwen38 / sensenova = 10 个适配器;插件示例 billing / usage。 验证:go build -tags luajit ./... OK;internal/lua 21.4s、provider、core 51s、 gateway 122.8s、e2e 9.0s 全绿。 --- README.md | 4 +- README_EN.md | 2 + cmd/gui/package.json | 2 +- deploy.sh | 71 +- docs/adapters.md | 5 +- docs/channel-issues-2026-10-19.md | 125 - docs/channel-repair-plan.md | 183 - docs/channels-parity.md | 540 - docs/deployment.md | 4 +- docs/features.md | 2 +- docs/git-workflow-en.md | 2 +- docs/git-workflow.md | 2 +- docs/gui.md | 2 +- docs/plugins.md | 4 +- e2e/channels_device_e2e_test.go | 170 - e2e/channels_flows_e2e_test.go | 209 - e2e/e2e_test.go | 8 +- internal/core/channels_account_adopt_test.go | 102 - internal/core/channels_account_probe_test.go | 105 - internal/core/channels_authorize_test.go | 182 - .../core/channels_autoclaim_cover_test.go | 111 - internal/core/channels_autoclaim_test.go | 159 - internal/core/channels_balance_tier_test.go | 122 - .../channels_buddy_catalog_filter_test.go | 85 - .../core/channels_callback_loopback_test.go | 220 - .../core/channels_capability_parse_test.go | 55 - internal/core/channels_capability_test.go | 86 - .../channels_clear_credentialless_test.go | 235 - internal/core/channels_cline_adapter_test.go | 91 - .../core/channels_codearts_adapter_test.go | 176 - .../core/channels_codearts_catalog_test.go | 174 - .../core/channels_codearts_oauth_flow_test.go | 186 - internal/core/channels_codearts_oauth_test.go | 112 - internal/core/channels_codearts_pem_test.go | 184 - internal/core/channels_codearts_port_test.go | 65 - internal/core/channels_dead_model_test.go | 217 - internal/core/channels_device_test.go | 40 - internal/core/channels_endpoint_test.go | 149 - internal/core/channels_endpoint_url_test.go | 76 - internal/core/channels_free_flag_test.go | 67 - internal/core/channels_gate_test.go | 65 - .../channels_legacy_source_cleanup_test.go | 105 - internal/core/channels_lobster_claim_test.go | 110 - .../core/channels_lobster_exchange_test.go | 75 - .../core/channels_lobster_loopback_test.go | 165 - internal/core/channels_log_panel_test.go | 198 - internal/core/channels_loomy_test.go | 177 - internal/core/channels_lua_locals_test.go | 73 - internal/core/channels_models_remote_test.go | 201 - internal/core/channels_more_test.go | 405 - .../core/channels_orphan_callback_test.go | 82 - internal/core/channels_orphan_source_test.go | 169 - .../core/channels_platforms_logged_in_test.go | 56 - internal/core/channels_plugin_test.go | 438 - internal/core/channels_pool_reason_test.go | 237 - internal/core/channels_pool_test.go | 477 - .../channels_private_adapter_wiring_test.go | 87 - internal/core/channels_qoder_adapter_test.go | 88 - internal/core/channels_qoder_claim_test.go | 295 - internal/core/channels_qoder_machine_test.go | 84 - internal/core/channels_qoder_test.go | 181 - internal/core/channels_raccoon_test.go | 234 - internal/core/channels_refresh_test.go | 260 - .../core/channels_remove_source_perm_test.go | 221 - internal/core/channels_reorder_effect_test.go | 241 - internal/core/channels_store_atomic_test.go | 125 - internal/core/channels_store_test.go | 226 - internal/core/channels_stress_test.go | 264 - internal/core/channels_trae_catalog_test.go | 230 - .../core/channels_trae_chat_headers_test.go | 117 - internal/core/channels_trae_checkin_test.go | 111 - .../core/channels_trae_credits_hdr_test.go | 71 - internal/core/channels_trae_derive_test.go | 63 - .../core/channels_trae_exchange_shape_test.go | 124 - internal/core/channels_trae_headers_test.go | 71 - internal/core/channels_trae_models_test.go | 111 - internal/core/channels_trae_pkce_test.go | 200 - internal/core/channels_trae_solo_sse_test.go | 130 - internal/core/channels_trae_test.go | 275 - internal/core/channels_ttl_cache_test.go | 100 - internal/core/channels_upstream_gaps_test.go | 67 - internal/core/channels_utf8clip_test.go | 38 - internal/core/channels_vnc_gpu_test.go | 59 - internal/core/channels_vnc_lifecycle_test.go | 70 - internal/core/channels_vnc_test.go | 153 - internal/core/channels_xdisplay_test.go | 203 - internal/core/channels_zcode_3012_test.go | 285 - internal/core/channels_zcode_identity_test.go | 411 - internal/core/channels_zcode_test.go | 188 - internal/core/codearts_sign_test.go | 97 - internal/core/device_harness_test.go | 180 - internal/core/device_mutate_test.go | 73 - internal/gateway/api.go | 2 +- internal/gateway/gateway_test.go | 32 - internal/gateway/plugins_api.go | 2 +- internal/lua/adapter_buddy_test.go | 116 - internal/lua/adapter_opencodezen_live_test.go | 227 - .../lua/adapter_opencodezen_reasoning_test.go | 112 - internal/lua/adapter_opencodezen_test.go | 161 - internal/lua/adapter_prelude.go | 4 +- .../lua/adapter_prelude_equivalence_test.go | 12 +- .../lua/adapter_prelude_error_shapes_test.go | 12 +- internal/lua/adapter_zcode_base_test.go | 97 - internal/lua/adapters/buddy.lua | 292 - internal/lua/adapters/kimicode.lua | 79 - internal/lua/adapters/opencodezen.lua | 586 - internal/lua/adapters/trae.lua | 292 - internal/lua/channels_gemini_adapter_test.go | 164 - internal/lua/channels_trae_solo_test.go | 102 - internal/lua/plugin_icons_test.go | 4 +- internal/lua/plugin_shard_config_test.go | 16 - internal/lua/plugins/channels.lua | 13612 ---------------- internal/lua/plugins/quota.lua | 1579 -- internal/lua/quota_backoff_test.go | 662 - internal/lua/quota_opendesign_test.go | 650 - internal/lua/store_dir_isolation_test.go | 61 - internal/lua/toolcall_preservation_test.go | 4 +- internal/lua/usage_plugin_test.go | 14 +- internal/lua/vm_test.go | 120 +- .../provider/registry_pinned_name_test.go | 8 +- packaging/core-dist.sh | 4 +- packaging/nfpm.yaml | 2 +- packaging/upload-release.sh | 4 +- 123 files changed, 76 insertions(+), 32056 deletions(-) delete mode 100644 docs/channel-issues-2026-10-19.md delete mode 100644 docs/channel-repair-plan.md delete mode 100644 docs/channels-parity.md delete mode 100644 e2e/channels_device_e2e_test.go delete mode 100644 e2e/channels_flows_e2e_test.go delete mode 100644 internal/core/channels_account_adopt_test.go delete mode 100644 internal/core/channels_account_probe_test.go delete mode 100644 internal/core/channels_authorize_test.go delete mode 100644 internal/core/channels_autoclaim_cover_test.go delete mode 100644 internal/core/channels_autoclaim_test.go delete mode 100644 internal/core/channels_balance_tier_test.go delete mode 100644 internal/core/channels_buddy_catalog_filter_test.go delete mode 100644 internal/core/channels_callback_loopback_test.go delete mode 100644 internal/core/channels_capability_parse_test.go delete mode 100644 internal/core/channels_capability_test.go delete mode 100644 internal/core/channels_clear_credentialless_test.go delete mode 100644 internal/core/channels_cline_adapter_test.go delete mode 100644 internal/core/channels_codearts_adapter_test.go delete mode 100644 internal/core/channels_codearts_catalog_test.go delete mode 100644 internal/core/channels_codearts_oauth_flow_test.go delete mode 100644 internal/core/channels_codearts_oauth_test.go delete mode 100644 internal/core/channels_codearts_pem_test.go delete mode 100644 internal/core/channels_codearts_port_test.go delete mode 100644 internal/core/channels_dead_model_test.go delete mode 100644 internal/core/channels_device_test.go delete mode 100644 internal/core/channels_endpoint_test.go delete mode 100644 internal/core/channels_endpoint_url_test.go delete mode 100644 internal/core/channels_free_flag_test.go delete mode 100644 internal/core/channels_gate_test.go delete mode 100644 internal/core/channels_legacy_source_cleanup_test.go delete mode 100644 internal/core/channels_lobster_claim_test.go delete mode 100644 internal/core/channels_lobster_exchange_test.go delete mode 100644 internal/core/channels_lobster_loopback_test.go delete mode 100644 internal/core/channels_log_panel_test.go delete mode 100644 internal/core/channels_loomy_test.go delete mode 100644 internal/core/channels_lua_locals_test.go delete mode 100644 internal/core/channels_models_remote_test.go delete mode 100644 internal/core/channels_more_test.go delete mode 100644 internal/core/channels_orphan_callback_test.go delete mode 100644 internal/core/channels_orphan_source_test.go delete mode 100644 internal/core/channels_platforms_logged_in_test.go delete mode 100644 internal/core/channels_plugin_test.go delete mode 100644 internal/core/channels_pool_reason_test.go delete mode 100644 internal/core/channels_pool_test.go delete mode 100644 internal/core/channels_private_adapter_wiring_test.go delete mode 100644 internal/core/channels_qoder_adapter_test.go delete mode 100644 internal/core/channels_qoder_claim_test.go delete mode 100644 internal/core/channels_qoder_machine_test.go delete mode 100644 internal/core/channels_qoder_test.go delete mode 100644 internal/core/channels_raccoon_test.go delete mode 100644 internal/core/channels_refresh_test.go delete mode 100644 internal/core/channels_remove_source_perm_test.go delete mode 100644 internal/core/channels_reorder_effect_test.go delete mode 100644 internal/core/channels_store_atomic_test.go delete mode 100644 internal/core/channels_store_test.go delete mode 100644 internal/core/channels_stress_test.go delete mode 100644 internal/core/channels_trae_catalog_test.go delete mode 100644 internal/core/channels_trae_chat_headers_test.go delete mode 100644 internal/core/channels_trae_checkin_test.go delete mode 100644 internal/core/channels_trae_credits_hdr_test.go delete mode 100644 internal/core/channels_trae_derive_test.go delete mode 100644 internal/core/channels_trae_exchange_shape_test.go delete mode 100644 internal/core/channels_trae_headers_test.go delete mode 100644 internal/core/channels_trae_models_test.go delete mode 100644 internal/core/channels_trae_pkce_test.go delete mode 100644 internal/core/channels_trae_solo_sse_test.go delete mode 100644 internal/core/channels_trae_test.go delete mode 100644 internal/core/channels_ttl_cache_test.go delete mode 100644 internal/core/channels_upstream_gaps_test.go delete mode 100644 internal/core/channels_utf8clip_test.go delete mode 100644 internal/core/channels_vnc_gpu_test.go delete mode 100644 internal/core/channels_vnc_lifecycle_test.go delete mode 100644 internal/core/channels_vnc_test.go delete mode 100644 internal/core/channels_xdisplay_test.go delete mode 100644 internal/core/channels_zcode_3012_test.go delete mode 100644 internal/core/channels_zcode_identity_test.go delete mode 100644 internal/core/channels_zcode_test.go delete mode 100644 internal/core/codearts_sign_test.go delete mode 100644 internal/core/device_harness_test.go delete mode 100644 internal/core/device_mutate_test.go delete mode 100644 internal/lua/adapter_buddy_test.go delete mode 100644 internal/lua/adapter_opencodezen_live_test.go delete mode 100644 internal/lua/adapter_opencodezen_reasoning_test.go delete mode 100644 internal/lua/adapter_opencodezen_test.go delete mode 100644 internal/lua/adapter_zcode_base_test.go delete mode 100644 internal/lua/adapters/buddy.lua delete mode 100644 internal/lua/adapters/kimicode.lua delete mode 100644 internal/lua/adapters/opencodezen.lua delete mode 100644 internal/lua/adapters/trae.lua delete mode 100644 internal/lua/channels_gemini_adapter_test.go delete mode 100644 internal/lua/channels_trae_solo_test.go delete mode 100644 internal/lua/plugins/channels.lua delete mode 100644 internal/lua/plugins/quota.lua delete mode 100644 internal/lua/quota_backoff_test.go delete mode 100644 internal/lua/quota_opendesign_test.go delete mode 100644 internal/lua/store_dir_isolation_test.go diff --git a/README.md b/README.md index eb28285..b24e8c2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ModelRouter +> **仓库**: +> > **English**: [README_EN.md](./README_EN.md) > > **AI-assisted**: 本项目使用 AI 辅助编程(代码与文档由 AI 协作完成,经人工复核)。 @@ -21,7 +23,7 @@ 保存的档位逐档调度,源故障自动降级并冷却,恢复后半冷却探测自愈。 - **协议适配在 Lua 层**:每个源挂一个 `.lua` 适配器,完成请求/响应/流式分片的双向 转换,并可用 `build_headers(meta)` 注入签名头。协议差异不进 Go 内核。 -- **插件独立**:业务扩展(渠道接入、计费、额度巡检)是外置 Lua 插件,由插件目录加载, +- **插件独立**:业务扩展(计费、用量统计等)是外置 Lua 插件,由插件目录加载, 不编进二进制、不随发行包分发;插件只通过 `modelrouter` SDK 与内核交互。 - **多租户密钥**:网关 key 与上游 key 相互独立,按 key 限定模型范围、AUTO 链与配额。 - **加密存储**:上游 key、自定义请求头、网关 key 均以 AES-256-GCM 落盘, diff --git a/README_EN.md b/README_EN.md index cb39f46..3e707f6 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,5 +1,7 @@ # ModelRouter +> **Repository**: +> > **中文**: [README.md](./README.md) > > **AI-assisted**: code and documentation were produced with AI assistance and diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 2a8f21f..cea3596 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -89,5 +89,5 @@ ], "asar": true }, - "homepage": "https://gitcode.com/JianFeeeee/ModelRouter" + "homepage": "https://atomgit.com/HeNanMU-EEA/ModelRouter" } diff --git a/deploy.sh b/deploy.sh index 108aaa2..53d0b05 100755 --- a/deploy.sh +++ b/deploy.sh @@ -435,78 +435,24 @@ sync_adapters() { chmod 0644 "$TARGET_ADAPTERS/"*.lua # 校验:每个适配器必须包含 usage 透传修复(opencode/openai/deepseek 等应有 'uses') - local required_files=(openai deepseek anthropic gemini ollama opencode github groq kimicode mistral sensenova agentrouter) + local required_files=(openai deepseek anthropic gemini ollama github groq mistral sensenova qwen38) for f in "${required_files[@]}"; do [[ -f "$TARGET_ADAPTERS/$f.lua" ]] || warn " 缺少适配器: $f.lua" done local usage_fixed=0 - for f in openai deepseek github groq kimicode mistral opencode; do + for f in openai deepseek github groq mistral; do 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 " usage 透传修复适配器: ${usage_fixed}/5 (openai/deepseek/github/groq/mistral)" log "OK 适配器已同步到 $TARGET_ADAPTERS/" } -# 同步内置插件到 plugin_dir。 +# 插件不在这里同步。 # -# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器 -# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份), -# 保留目录里运行时新增/运维自加的 .lua。 -# -# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口: -# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样 -# 全部卡在仓库里 —— 二进制换了、/etc/modelrouter/plugins/channels.lua 还是旧版, -# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。 -# -# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置 -# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。 -sync_plugins() { - log "同步内置插件" - - SRC_PLUGINS="$REPO_DIR/internal/lua/plugins" - [[ -d "$SRC_PLUGINS" ]] || fail "内嵌插件目录不存在: $SRC_PLUGINS" - - mkdir -p "$TARGET_PLUGINS" - - # 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动) - BACKUP_DIR="/etc/modelrouter/plugins.bak.$(date +%Y%m%d%H%M%S)" - mkdir -p "$BACKUP_DIR" - local added=0 updated=0 same=0 - for src in "$SRC_PLUGINS"/*.lua; do - [[ -f "$src" ]] || continue - local name; name="$(basename "$src")" - if [[ -f "$TARGET_PLUGINS/$name" ]]; then - if cmp -s "$src" "$TARGET_PLUGINS/$name"; then - ((same++)) || true - continue - fi - cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true - cp -f "$src" "$TARGET_PLUGINS/$name" - chmod 0644 "$TARGET_PLUGINS/$name" - log " ~ 更新内置插件: $name" - ((updated++)) || true - else - cp -f "$src" "$TARGET_PLUGINS/$name" - chmod 0644 "$TARGET_PLUGINS/$name" - log " + 新增内置插件: $name" - ((added++)) || true - fi - done - # 没有任何改动时不必留空备份目录 - if (( updated == 0 && added == 0 )); then - rmdir "$BACKUP_DIR" 2>/dev/null || true - else - log " 旧插件已备份到 $BACKUP_DIR" - fi - - # 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在 - for src in "$SRC_PLUGINS"/*.lua; do - [[ -f "$src" ]] || continue - local name; name="$(basename "$src")" - [[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name" - done - log "OK 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)" -} +# 早期实现会把内置插件复制/覆盖到 plugin_dir,理由是"内置插件是发行物的一部分"。 +# 该前提已被废止:插件不再编入二进制、不进发行包、不自动安装,属于运维自有资产。 +# 部署脚本只负责适配器(协议转换,随内核发布)与二进制/配置,绝不创建、覆盖或 +# 删除 plugin_dir 里的任何文件。 # ---------- 重启服务 ---------- restart_service() { @@ -630,7 +576,6 @@ main() { verify_master_key build sync_adapters - sync_plugins backup_old_state atomic_replace_binary atomic_replace_config diff --git a/docs/adapters.md b/docs/adapters.md index 4abd0e6..2d07430 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -47,14 +47,15 @@ return { ### 内置适配器 -`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode` +`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `qwen38` `sensenova` `qwen38` `sensenova` `trae`。 `anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式); 推理开关由中立的 `thinking` 字段表达,`deepseek` 适配器把它映射为 `thinking.type = "disabled"`。 -**kimicode** 是展示 `build_headers` 的样例:云端校验调用方 app,需要按 +适配器可用 `build_headers(meta)` 注入签名头。示例见 `adapters/` 下任一脚本的 +`build_headers` 实现;云端校验调用方 app 时需要按 `meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。 配好 `sources[].meta.{app_id, app_secret, app_agent}` 即可。 diff --git a/docs/channel-issues-2026-10-19.md b/docs/channel-issues-2026-10-19.md deleted file mode 100644 index 10e2684..0000000 --- a/docs/channel-issues-2026-10-19.md +++ /dev/null @@ -1,125 +0,0 @@ -# 渠道问题清单(2026-10-19 晚生产实测) - -> 逐项修复。每项标注:现象 / 根因 / 状态。 - -## P1 trae 非流式 500(unmarshal unified response: invalid character 'e') -- **现象**:`stream:false` 打 `channels-trae:*` 必现 500,body 以 `event:metadata data:{...}` 开头。 -- **根因**:trae SOLO 上游**对非流式请求也回 SSE**(且帧带 `event:` 前缀)。 - 插件自装的 `channels-trae` 适配器 `transform_response` 直接 `__prelude.openai_response(raw_body)`, - 不识别 SSE;而内核只把 `data:` 行交给流式适配器,非流式路径把**整个 body**(含 `event:` 行) - 塞给 transform_response → json.decode 失败 → 原样返回 → Go 侧 unmarshal 报 `invalid character 'e'`。 - (`internal/lua/adapters/trae.lua` 有 SSE 聚合逻辑,但 channels 平台用的是**插件内嵌** - `TRAE_SOLO_ADAPTER_SRC`(channels.lua:9744),两份代码不同步。) -- **修法**:`TRAE_SOLO_ADAPTER_SRC.transform_response` 加 SSE 聚合(复用流式帧解析, - 聚合成一个非流式响应),并把 `internal/lua/adapters/trae.lua` 的聚合逻辑对齐。 -- **状态**:✅ 已修复(`aggregate_solo_sse`)并部署生产,实测非流式返回 200 正常内容。 - -## P2 buddy(CodeBuddy)非流式 400(11101 Non-stream not supported) -- **现象**:`stream:false` 打 `channels-buddy:*` 上游 400 `11101: Non-stream chat request is currently not supported`。 -- **根因**:腾讯 codebuddy 网关**只支持流式**。适配器 `transform_request` 没有强制 `stream=true`, - 非流式请求原样透传被上游拒绝。buddy 的 `Accept: text/event-stream` 头已发,但 body.stream 仍是 false。 -- **修法**:`buddy.lua transform_request` 强制 `req.stream = true`;`transform_response` 增加 - SSE 聚合兜底(内核非流式路径收到 SSE 时聚合)。参考 opencodezen.lua 的既有做法(门禁第 4 条)。 -- **状态**:✅ 已修复并部署生产,实测非流式返回 200 正常内容。 - -## P3 buddy 偶发 provider busy(429)+ 11133 参数拒绝 -- **现象**:连续请求 `channels-buddy` 间歇返回 `provider busy`(source 并发槽 max_concurrent=1 - 被占满)与上游 `11133: the request parameters were rejected`。 -- **根因**:① max_concurrent=1 是**刻意的**(channels.lua:4332 注释:并发配额按模型计),busy - 是正常自保;② 11133 与空 tool_calls 修复(未提交的 buddy.lua 改动)相关,待部署后观察。 -- **状态**:观察项,非缺陷。 - -## P4 Qoder 领取失败(缺少凭据对象)——已修复 -- **现象**:`channels/claim` 报 `{"error":"缺少凭据对象"}`,而 account_test 正常。 -- **根因**:qoder 是 PKCE 设备码流且无 register 第二跳,落库凭据是**裸 token 字符串**; - `qoderClaim` 硬要求 `type(cred)=="table"`。 -- **修法**:`qoderClaim` 兼容裸 token(非空时包 `{access_token=tok}`); - 回归测试 `TestQoderClaimAcceptsBareTokenCredential`。 -- **状态**:✅ 已修复并部署生产(claim 现在返回真实上游业务结论)。 - -## P5 Qoder/OpenCode 渠道页账号串台(UI 渲染竞态) -- **现象**:从 qoder 切到 opencode 详情页,qoder 的账号行闪现在 opencode 卡片下。 -- **根因**:`renderDetail()` 切渠道时不清空 `#ch-accts`,`renderAccounts` 是异步的, - 空窗期 DOM 仍挂旧渠道账号行;且慢的旧请求返回会覆盖新渠道数据。 -- **修法**:① renderDetail 立即清空账号/模型区并置"加载中";② renderAccounts/renderModels - 加代际校验(`state.selected !== id` 时丢弃响应)。 -- **状态**:✅ 已修复并部署生产。 - -## P6 CodeArts「完全不可用」——多数模型实际可用 -- **现象**:用户报"codearts 完全不可用"。实测逐模型探测: - - 可用:openpangu-2.0-flash/pro、GLM-5.2、glm-5.3、kimi-k2.6-vl、Qwen3-VL-235B、glm-5.2-sft-harmony - - 429(限流非下架):deepseek-v4.1-flash、deepseek-v4-flash、openpangu-2.0-pro(间歇) - - 404(真下架,需标 disabled):GLM-5.3 报 `InferHub.002002009.404: The model is not registered` -- **附带发现**:`GLM-5.3`(大写变体)不在 deadModels 记录里——`recordDeadModel` 只被 - `__record_dead_probe` 调用,**request_end hook 没接 isModelGone**(583976f 引入的 - 基础设施丢了挂接点)。需要把 on_request_end 补上 dead-model 记录。 -- **凭据**:expiresAt 已过期(15:05),`account/refresh` 续期成功(refreshable=true)。 - 建议把「凭据到期自动续期」纳入定时任务检查。 -- **修法(2026-10-20 完成)**: - - `on_request_end` 补上 `isModelGone` + `recordDeadModel` 挂接(P9 一并解决); - - **凭据自动续期失效的真根因**:`credentialExpiresAtMs` 把 ISO 过期时刻按**本地时区** - 解析,而 codearts 给的是 UTC ISO(`2026-10-19T16:42:43.06Z`)⇒ 本机 +08 下过期时刻 - 虚早 8 小时 ⇒ `refresh_creds` 每 30 分钟都判"已过期"并对华为云真打一次 STS - (token 本来还有 23 小时寿命)。已改为按声明时区解析(Z/±hh:mm/无后缀=UTC 三种形态 - 同一 epoch),回归测试 `TestCredentialExpiresAtMsParsesISOAsUTC`。 -- **状态**:✅ 已修复并部署生产。 - -## P7 CodeBuddy 海外账号 / Qoder 国内账号 -- **现象**:CodeBuddy 登录只走国内(copilot.tencent.com),Qoder 登录只走国际 - (openapi.qoder.sh / qoder.com)。用户要求两边都能选区。 -- **根因**:平台表单区硬编码。buddy 的 state-poll 端点与 Qoder 的 PKCE 端点都锁死一个区域。 -- **修法(2026-10-20 已实施,账号级 region)**: - - 凭据 JSON 增加 `region` 字段(`intl`|`cn`,缺省 = 历史行为); - - `start_login` 接受 `region`,会话带着它选端点;登录完成写进凭据; - - **CodeBuddy**:cn = `copilot.tencent.com`(platform=ide),intl = `www.workbuddy.ai` - (platform=`workbuddy-ai`,X-Domain/X-Product-Code/UA 按区域切换;实测 - `POST /v2/plugin/auth/state?platform=workbuddy-ai` 回 200 信封); - - **Qoder**:intl = `openapi.qoder.sh` + `qoder.com`(client_id e883ade2-…), - cn = `openapi.qoder.com.cn` + `qoder.cn`(client_id **不同**:732aef47-…, - 照参考 QODER_CN;用国际版 id 授权页报「参数无效」)。CN 无公开 chat 端点 - (参考实测 503)⇒ CN 账号只做登录/领取,不产源; - - UI:buddy/qoder 详情页多一个「+国际区」按钮(默认国内/国际主区)。 -- **状态**:✅ 已实施并部署生产。 - -## P8 商汤(Raccoon)扫码登录「链接无效」——已修复 -- **现象**:用户点登录看到的链接打不开(404)。 -- **根因(两个,2026-10-20 实测定位)**: - ① 登录页 `/login/mp` **已被站点删除**(Next 路由 404,只有 404 页面返回); - ② 旧实现**本地随机造 qrcode_code**,而上游早已不认自造码:旧端点 - `/api/web/auth/v1/login_with_qrcode_code` 对任意 code 恒回 `pending` - (服务端根本没有该会话)⇒ 扫码永远完不成,账号一直空凭据。 -- **修法**:改为上游现行 IAM 流程(逐行对齐其网页客户端 auth chunk): - ① `POST /api/iam/v1/auth/mp/qrcode` 由**服务端建会话**(返回 qrcode_code / - scan_path / expires_in / poll_interval); - ② 链接/二维码内容 = `https://xiaohuanxiong.com/login?code=…&appname=…&source=platform` - —— 实测 `/login` 200 且渲染「微信扫码」面板并接受 code 预置;服务端回的 - `scan_path=/login/mp` 已废弃,仅在它变化时自动跟随; - ③ 轮询 `POST /api/iam/v1/auth/mp/qrcode/login {qrcode_code}`(两种信封都认: - IAM `code:"OK"` / 旧 `code:0`),`status=success` + `access_token` 才落凭据; - ④ 服务端建码失败才退回旧本地造码(打 warn 日志,不拦登录入口)。 -- **状态**:✅ 已修复并部署生产。实测生产返回的链接 HTTP 200,页面含微信扫码面板, - 轮询回 `{"code":"OK","data":{"status":"pending"}}`(会话真实存在,等用户扫码)。 - -## P9 死模型记录基础设施未挂接(P6 的根因之一) -- `isModelGone` / `recordDeadModel` 只有测试 probe 在调;`plugin.hooks = { request_end }` 的 - `on_request_end` 只处理 term_error(账号级),不记模型级死亡。 -- **修法**:on_request_end 失败分支里,取 payload.model + error message, - `isModelGone(msg)` 为真时 `recordDeadModel(provider, model, msg)` 并重同步源。 -- **状态**:✅ 已修复并部署生产(真实 404 现在会进 deadModels)。 - -## 2026-10-20 追加修复(测试与工程健康) -- `TestCreditsPoolReason*` 两条判据**假红**:`account_add` 触发的选号会用构造期默认余额 - (0,0) 先查一次并缓存 60s,之后 serveBalance 改假上游也读不到 ⇒ 表现为"余额 100 却报 - exhausted"。修法:harness 在改余额后清 TTL 缓存(`__ttl_reset_probe`,与生产同一实现)。 -- `TestAccountReorderActuallyReordersKernelSources` / `TestPoolListOrderIsDeterministic` - **已随「一通道一源」失效**(每个通道只剩一个源,按 `channels-buddy-` 过滤恒得空): - 判据改写为——重排必须切换**服务账号**(`__chosen_account_probe`),顺序确定性改为 - 断言选号顺序(`__rank_accounts_probe`)由 id 稳定决定。 -- channels.lua 顶层 local 余量回归绿:区域表/适配器源码常量收进 `plugin.K` - (`TestChannelsLuaHasLocalHeadroom` 需要 ≥10 槽位)。 - -## 部署注意事项 -- 工作树有未提交改动:buddy.lua / trae.lua(tool_calls 空 {} 过滤)、provider.go(stop→tool_calls 归一)、 - channels.lua(qoder 裸 token + UI 竞态)、provider_test.go、channels_qoder_claim_test.go。 -- 生产 channels.lua 哈希与仓库不同步是常态(deploy.sh 会 sync_plugins)。 -- 商汤账号:用户需在二维码有效期内扫码确认;当前账号 5a2ea34df521 无凭据属正常(未完成扫码)。 diff --git a/docs/channel-repair-plan.md b/docs/channel-repair-plan.md deleted file mode 100644 index a174aa1..0000000 --- a/docs/channel-repair-plan.md +++ /dev/null @@ -1,183 +0,0 @@ -# 渠道链路修复计划与现状 - -> 本文是 `plan.md` 中「渠道链路修复计划(2026-10-17 起)」章节的**可提交版本**。 -> `plan.md` 本身被 `.gitignore` 忽略(本地工作台账),故关键计划与现状单独落在这里, -> 以便随代码一起进 git、供他人查阅。 -> -> **写作原则:只写实测到的,标注"推断"的必须写明。** 每条缺陷都附可复现的观测。 - -> 记录 **2026-10-17 用户报障三连修**(模型不可用 / 额度异常 / 其他渠道同类) -> 的现状与后续计划。上接 `plan.md` §9(v2 契约收尾)。 - -## 1. 现状快照(2026-10-17 14:00 实测,非推断) - -### 1.1 生产实例 - -| 项 | 值 | -|---|---| -| 服务 | `active`,NRestarts=0 | -| 二进制 sha256 | `56396f79bfc2433a`(与仓库逐字节一致) | -| 插件 sha256 | `c15cb4bf5d5c0f21`(与仓库逐字节一致) | -| 插件 | 4 个(billing/channels/quota/usage)**全部 loaded** | -| 源 / 适配器 | 21 源 / 23 适配器 | -| 账号池 | **仅 trae、buddy 各有 1 个有凭据账号**;其余 10 个平台**账号数 0** | - -### 1.2 各渠道缺陷矩阵(本文件写入时的实测) - -自装适配器(插件在加载时经 `__llmsproxy_adapters` 装入): - -| 适配器 | `build_headers` | 静态 `adapter.headers` | 流式转换 | 鉴权是否可能错 | -|---|---|---|---|---| -| `codearts` | 有 | 无 | 复合 openai | 有(SDK-HMAC 自签,已实现) | -| `channels-trae` | **有(本次新加)** | 无 | **SOLO 自定义(本次新写)** | 已修 | -| `channels-gemini` | **★ 缺** | 有 | 自定义 | 静态头 + Bearer,**待确认** | -| `channels-zcode` | **★ 缺** | 无 | 无(继承 anthropic 基座) | **★ 高度可疑** | -| `channels-cline` | **★ 缺** | 无 | 套 openai | **★ 可疑**(要 `Bearer workos:`) | -| `channels-qoder` | **★ 缺** | 无 | 套 openai | **★ 可疑**(头由 WASM 生成) | - -内置适配器里 `build_headers` 只有 3 个有(buddy / kimicode / opencodezen), -其余 11 个(含 **anthropic 基座**)靠静态头 + 内核兜底 `Bearer`。 - -> **★ anthropic 基座本身没有 `build_headers`**,而 anthropic 协议实际要 -> `x-api-key` 而非 `Authorization: Bearer`。`channels-zcode` 继承它 ⇒ -> 一旦有账号就会立刻暴露,与 trae 修复前完全同型。 - -### 1.3 已验证可用的通道(生产实测,不是单测) - -| 模型(源限定名) | 结果 | -|---|---| -| `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` | ✅ 80 帧,正文 27 + 推理 299 | -| `channels-trae-b8dd6b3df30e:glm-5.2` | ✅ OK | -| `channels-buddy-buddy-main:hy4-preview` | ✅ OK | - -| 端点 | 结果 | -|---|---| -| `channels/credits/status` (trae) | ✅ `active:true, dailyCredit:100, todayCheckedIn:true` | -| `channels/credits/balances` (trae) | ✅ `total:598.516 credits` | - -### 1.4 本轮已修(代码已部署生产,**尚未提交 git**) - -**① 网关解不开 `源名:模型名` 限定名**(`internal/provider/registry.go`) - -- 根因:`ResolvePinned` 用 `strings.IndexAny(model, "-:/")` 找**第一个**分隔符。 - 渠道源名形如 `channels-trae-b8dd6b3df30e`(**含 `-`**)⇒ 切成 `src="channels"` - ⇒ 永不匹配 ⇒ `Resolve()` 返回 nil ⇒ `model "..." is not configured`。 -- 为什么必须支持:本实例 **44 个模型被多源同时提供**(`glm-5.2` 由 6 个源提供), - WebUI 下拉框用 `源名:模型名` 作 option value 是**有意设计**。 -- 修法:抽 `splitPinned()`,**按已知源名做最长前缀匹配**(源名是已知集合, - 唯一可靠办法),保留旧 `-:/` 兜底。`EffectiveModel` 同步修。 -- 判据:`internal/provider/registry_pinned_name_test.go`(4 用例,变异 1/1 咬住)。 - -**② trae 适配器三连错**(`internal/lua/plugins/channels.lua`,逐层剥开) - -1. **没有 `build_headers`** ⇒ 内核兜底发 `Bearer`,而 trae 推理端点要 - `Cloud-IDE-JWT` + 设备身份头 ⇒ 401「not able to authenticate you」。 - → 与「额度不可用」是**同一根因的两个出口**(额度端点当轮修了、推理端点漏了)。 -2. **header 常量值是错的**:`X-App-Id` 抄成 `6eefa6c9`(真实 - `6eefa01c-1036-4c7e-9ca5-d891f63bfcd8`)、`app_version_code` 用 `0.1.52` - (真实是**日期式** `20260811`)⇒ 上游 `4001 binding: expr_path=app_version_code, - cause=parameter type does not match binding data`。 -3. **流式转换完全不对**:TRAE 是 **SOLO 自定义 SSE** - (`event:output` → `data:{"response":"###","reasoning_content":...}`)。 - 套 `openai_stream_chunk` ⇒ 无 `choices` ⇒ `empty stream`; - 改成 OpenAI 外壳 `{choices:[{delta}]}` **仍不对** —— 内核 - `json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构**,外壳字段全丢。 - ⇒ 必须输出扁平 `{content, reasoning_content, usage, done, finish_reason}`。 -- 判据:`channels_trae_chat_headers_test.go`(3)、`channels_trae_solo_sse_test.go`(2), - 变异 2/2 咬住。 - -**③ 余额恒为 0**(同文件 `balance.total_fn`) - -- 根因:读 `p.credits_limit`(pack **顶层**)—— 该字段**根本不存在**。 - 真实路径是 `pack.entitlement_base_info.quota.credits_limit` - (参考 `trae-credits.ts:341`)+ `pack.usage.credits_amount`。 -- 实测上游:`免费`(无 credits_limit) / `每月登录赠送`(500) / `签到奖励`(100) = **600**。 -- 修法:读嵌套路径 + 保留顶层兼容 + `usage_summary.{total,consumed}_amount` 兜底。 -- 判据:`channels_trae_credits_hdr_test.go`(1)。 -- 修后余额 **600 → 实测 598.516**(真实扣减)。 - -**④ 附带**:`channels_trae_test.go` 夹具从**扁平** `{"Token":...}` 改为真实 -`{"Result":{"Token":...}}` —— 原来夹具形状与生产不一致,**判据必然虚假通过** -(这是 ExchangeToken 缺陷藏了很久的原因)。 - -## 2. 后续计划 - -### P0 — 提交本轮修复(必须先做) - -代码已部署生产但**未进 git**。必须: -1. 在 `release/v2.0.x` 上提交(含 3 个源文件 + 6 个判据文件) -2. 打 tag `v2.0.40`(**这是内核 Go 改动**:`registry.go` ⇒ 真核心版本) -3. 回流 main(cherry-pick,不 merge) -4. 复核 tag 落点 + release vs main 零差异 + 生产 sha256 一致 - -> 判据:`git tag --points-at ` 非空;`git diff release/v2.0.x main` 为空。 - -### P1 — 其余渠道的同类缺陷(**账号 0,验证不了**) - -按"风险 × 可验证性"排序。**核心限制:这 4 个平台账号数均为 0, -无法做生产实测** —— 只能靠静态对齐参考 + 装好适配器后看 `__adapter_install_probe`。 - -| 优先 | 项 | 缺陷 | 修法 | 可验证性 | -|---|---|---|---|---| -| P1-1 | `channels-zcode` | 走 anthropic 基座,而**基座无 `build_headers`**,anthropic 要 `x-api-key` 不是 Bearer | 给基座补 `build_headers`(`x-api-key` + `anthropic-version`),zcode 继承即得 | 静态可验;运行需账号 | -| P1-2 | `channels-cline` | 参考要 `Bearer workos:`,我们的 token 无 `workos:` 前缀 | 登录/续期处拼前缀,或适配器 `build_headers` 补 | 静态可验;运行需账号 | -| P1-3 | `channels-gemini` | 有静态头 + Bearer(参考注释也说 Cloud Code 用 OAuth Bearer)⇒ **可能本来就对** | 只需确认,不改 | 静态可验 | -| P1-4 | `channels-qoder` | 头由 **WASM 生成必须原样透传**,我们拿不到 `.wasm` | **阻塞**:二进制不在仓库任何位置 | 不可验证 | - -### P2 — 内核对"限定名"的契约收口 - -`ResolvePinned` / `EffectiveModel` 现在各自调 `splitPinned`。建议: -- 把"源名可含哪些字符"写进 `docs/`,并加判据:新增源名若含 `:` 或 `/` 必须报警 - (否则限定名会歧义)。 -- 现状:源名由插件生成 `channels--`,只含 `-`/字母数字,安全。 - -### P3 — 判据防回归(防"夹具与生产不一致"再犯) - -本轮最大的教训不是某个 bug,而是**夹具形状错 ⇒ 判据虚假通过**(trae 活了两轮)。 - -建议加一条**通用**判据:`TestAdaptersFixturesMatchRealUpstreamShape` —— -对每个有夹具的渠道,把夹具响应体与 -`/tmp/pi-github-repos/.../vendor/channel-pack/src/*.ts` 里注释记录的真实响应形状 -做**结构对照**(键集合),不一致即报红。做不到全自动,至少把已知的 -"Result 信封 / SOLO SSE / 嵌套 quota" 三类钉住。 - -### P4 — 待用户决策 - -| 项 | 需要什么 | -|---|---| -| zcode 真实登录 | 用户点授权(身份块 + init-poll 流已就绪) | -| cline / qoder / gemini / codearts / minimax / lobsterai / opencode | 需各平台账号;`device-poll` 系列要用户去官方页输码 | -| raccoon / loomy | 需扫码 | -| qoder WASM | 用户提供 `.wasm` | - -## 3. 风险与纪律(本轮新增) - -1. **`luac -p` 对长字符串内的适配器代码无效**(只看外层)⇒ 会得到假绿。 - **必须**用 `__adapter_install_probe` 看真实装载结果。本轮我因此把 TRAE 的 - SSE 代码误塞进 CODEARTS 适配器,导致 codearts 装载失败而 `luac` 一直通过。 -2. **Python 字符串替换会静默不生效**:锚文本缩进不符 ⇒ 匹配 0 次。 - 规矩:① 先 `cp` 备份 ② **行号定位** ③ 写后 `luac -p` + `grep -c` 双确认 - ④ 长字符串内的改动必须验装载。 -3. **`function` 是 Lua 关键字**:`cc.function` 是语法错误,必须 `cc["function"]`; - 但批量替换时别把 `cc.function_call` 一起改坏(本轮踩过,导致 - `')' expected near '_call'`)。 -4. **判据要咬被测对象**:本轮 `channels_trae_credits_hdr_test.go` 第一版造 HTTP 假上游, - 因 URL 覆盖/凭据 ref/路径匹配任一环错,"没发请求"伪装成"头不对"(耗了三轮)。 - 改成直接咬 `spec.headers_fn` 的真实产出,一轮就准。 -5. **只改插件不必换二进制**;改内核 Go 必须换。混着发会把"插件改动"记成"核心版本" - (v2.0.21–v2.0.39 有 18 个 tag 是这么来的,见 §4)。 -6. **`/tmp` 是 8.8G tmpfs**,Go 链接失败常见于它满(`no space left on device`)。 - 元凶通常是 `/tmp/gocache`(可安全清)。 - -## 4. 账务实况(必须记住) - -| 事实 | 值 | -|---|---| -| 内核 Go 代码自 v2.0.20 起的**全部**改动 | 仅 `internal/lua/adapter_prelude.go`、`internal/provider/provider.go`、`internal/provider/registry.go` | -| 只改插件却发了核心 tag 的版本 | v2.0.21–v2.0.39 中的 **18 个** | -| 当前最新 tag | `v2.0.39`(`091f790`),落点正确 | -| main | `56ea269`(与 release 内容零差异) | -| **未提交的改动** | 本轮三个修复(见 §1.4),已部署生产 | - -> ⇒ **版本号语义教训**:以后"改插件不打核心 tag",插件自身维护版本; -> 内核版本只在 Go 代码变更时递增。 diff --git a/docs/channels-parity.md b/docs/channels-parity.md deleted file mode 100644 index 8e87b32..0000000 --- a/docs/channels-parity.md +++ /dev/null @@ -1,540 +0,0 @@ -# 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 MODELROUTER_PLUGIN_DIR → settings.store_dir → 读 /etc/llmsproxy/config.yaml 的 plugin_dir → 硬编码 -``` - -测试台没设 `MODELROUTER_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`)设了 `MODELROUTER_PLUGIN_DIR`。**这是隔离做得不一致**的代价。 - -同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。 - -## 为什么 `DisableStatePersistence` 不够 - -第一版修复给 `runtimePlugins` 关了持久化,**仍然复现**:插件声明了 `schedule` -时内核会自动起调度器(`plugins.go: p.Runtime = ps.startRuntime(...)`), -真 channels 的 `refresh_models` 会在 5s 后(first_due_now)跑一轮并 -`logEvent → markDirty`。那是**后台 goroutine,比 `t.Setenv` 的生命周期长** —— -测试结束后它仍可能落盘,此时环境变量已还原。 - -## 修法(两层,第二层才是根治) - -1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜): - 新增 `__llmsproxy_plugin_dir` 全局(`registerPluginDirBridge`), - `storeDir()` 优先用它。**即使没有环境变量也不会落到生产路径** —— - 实测:撤掉 env 后 `storeDir` 仍落在临时目录。 -2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()`。 - -## 判据 + 变异验证 - -`internal/lua/store_dir_isolation_test.go`: -`storeDir` 绝不能是 `/etc/llmsproxy` 前缀、必须落在临时目录、env 必须真的被设。 -**变异验证**:把隔离设成生产路径 → 判据变红并打印「storeDir 指向了**生产**目录」; -恢复 → 变绿。 - -全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。 - -## ⚠️ 修复过程中我自己踩的坑 - -恢复账号时我把**保险库文件里的密文**当成明文,喂给 `account/add` 的 -`credential` —— 保险库 `loadSecretsLocked` **会在读取时解密**,于是那份凭据被 -**二次加密**(1971 → 2965 字符),该账号的推理开始返回空 -(`codebuddy_*` 源失效,而非池路径 `channels:buddy` 正常)。 - -教训:**凭据只能由登录/续期路径写入,绝不要手工把密文塞回 API**。 -要复用既有凭据,应走「让插件自己读保险库」的路径,或者重新登录一次。 - ---- - -# 逐模块对照的当前状态(2026-10-14 更新) - -穷尽对照参考仓 **143 个 .ts 模块 / 77,720 行**。约定: -✅ 已对齐 · ⚠️ 部分/简化 · ❌ 未实现 · ⛔ 明确不做 - -## 本轮(第 2 批)新补的 - -| 模块 | 参考规模 | 补了什么 / 为什么 | -|---|---|---| -| `dead-model-store` | 708 | 上游下架的模型仍显示且标"免费",一点就 404。**判据只用阳性 404 证据,绝不拿远端目录反推**(buddy 那类平台目录残缺,反推会删可用模型) | -| `remote-catalog-gate` | 114 | 参考点名"不能省":目录拉不到会放大成「一次请求 × 每个模型」,首屏卡死 | -| `buddy-balance-rank` + `loomy-balance-rank` + 两个 selector | ~1000 | **余额分档选号**:优先用「15 天内到期」的号,否则积分白白作废;锁定时只剩永久的号等同不可用 | -| `account-probe` | 568 | `account_test` **此前一个网络请求都不发**(恒返回 ok:true/ms:1)—— 假阳性 | -| `model-gate` | 218 | 源改**串行 + 按模型节流**(并发发起必然浪费一个配额;串行不保证间隔) | -| `ttl-cache` / `serial-queue` | 291 | 抽成通用件,消掉三处各自手写的缓存/冷却逻辑 | -| `opencode-capability` | 423 | isFree 改由 models.dev 的 `cost` 权威决定(不再是硬编码快照)。**受内核 4 MiB 上限约束不可用**,已如实降级为可选增强 | - -另修 3 个**静默失败**:`account_test` 不发请求、业务级错误(HTTP 200 + body 带 -error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了)。 - -## 仍未实现(按影响面排序) - -| 模块 | 行数 | 为什么不做的理由 / 阻塞 | -|---|---|---| -| `zcode-captcha` + 6 个 captcha-* | ~3345 | 阿里云滑块。**登录不需要它** —— captcha 只在签到/领取环节(`zcode-auth.ts:22-23`)。真实价值是"让领取全自动"而非"让平台可用" | -| `qoder-wasm` + `qoder-envelope` | 932 | **wasm 二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。拿不到就无法实现 | -| `zcode-identity` / `transport` / `diagnostics` | 1104 | 设备身份 + 传输层 + 错误诊断。与 captcha 链路耦合 | -| `account-pool` / `channel-pack-store` | 1912 | 我们已自建(形态不同,非缺口) | -| `opencode-proxy` / `opencode-rpc` | 530 | 多出口代理(NAT 后多机共享出口时按 IP 限流才需要) | -| `message-shape` | 146 | **不适用**:那是 DSH 宿主的消息形状迁移(0.1.5→0.1.7)。我们的适配器直接透传 OpenAI 形状,无此问题 | -| `loop-recovery` | 346 | **前提不存在**:它做的是"循环守卫中止后自动续跑",而我们的内核**没有循环守卫** | -| `reasoning-ladder` | 182 | 需要内核支持 `efforts` 字段(`Model` 结构只有 id/priority/kind/meta)。属内核改动,按纪律不做 | -| `badge-*` / `image-budget` / `ttl-cache`(已补) | ~500 | 徽标订阅、图片预算 —— 宿主 UI 特性 | -| `raccoon-login-page` / `raccoon-qr` | 1124 | 登录页与二维码的完整实现(我们走简化版) | -| `zcode-carrier-page` | 93 | 内部载体页(依赖 DSH Desktop 的 webview guest,我们没有那个壳) | -| `cline-*` 4 个 / `gemini-project` / `gemini-sigstore` / `loomy-sign`(部分) | ~900 | 平台细节与签名持久化 | - -## 一句话结论 - -**平台登录/凭据/源注册这条主线已打通且逐平台对齐**(12 平台零 manual、三平台 -自装适配器、SOLO/信封/AK-SK 签名逐字节验证)。 -**未实现的部分集中在两类**:(1) 需要**真实浏览器/验证码**的领取全自动 -(captcha 系 ~3300 行);(2) **二进制缺失**(qoder wasm)。 -其余是宿主 UI 特性或前提不存在的模块。 - ---- - -# 第三批(2026-10-14 晚)—— 逐模块对照的**新发现** - -## ★ 本轮新补的三个真缺陷 - -### ① 自动领取每天静默漏领(`credits.ts` / `auto-checkin.ts`) -活动刷新时刻**各平台不同**(Qoder 每日 10:00 UTC+8),而我们只在 08:00 跑一次 -⇒ 上午看到昨天那条 `CLAIMED`、报 `already-claimed` 并记账 ⇒ 当天再不去领。 -**修法照参考**:不配"时刻表"(必然漂移),让**产出方声明 `covers_today`**, -记账方只数"有几条能证明今天已处理"。两层都要:频率提高 + `first_due_now`。 - -### ② 凭据续期此前是假的(`refresh.ts` / `expiry-sync.ts`) -`account_refresh` **一个换 token 的请求都不发**却恒返回 `ok:true` -⇒ `refresh_creds` 打「已续期 N 个」而什么都没续 —— **日志在说谎**。 -这是本轮第三次遇到同形态(前两次:`account_test` 恒 ok、业务级错误被当成功)。 - -### ③ `isFree` 与声明自相矛盾(`cline-models.ts`) -cline 的免费模型声明在 `models` 里 ⇒ 被标成收费。 -★ 关键认识:**两个参考用的是不同机制** —— cline 用远端 `free[]`, -opencode 用 models.dev 的 `cost`。混成一条 OR 链会与既有判据正面冲突。 -⇒ 加 `free_source` 由**平台声明**权威来源。 - -## 插件自装适配器(第 3、4 个) -| 适配器 | 解决的问题 | -|---|---| -| `channels-qoder` | 错误信封**双层编码**;`10605`(排队) 与 `110`(额度耗尽) 语义**相反**,必须分开说(参考实测:110 原被归成通用服务端错误 → 白重试 5 次 ≈15.5 秒) | -| `channels-cline` | 429 **无 retry-after 头**、等待时长写在英文句子里(`Try again in 19h 39m`)。解析不出 ⇒ 内核按 30 秒短冷却 ⇒ **每 30 秒猛敲上游**(比参考的「永远 60 分钟」更糟) | - -现共 4 个:`channels-trae` / `channels-gemini` / `channels-qoder` / `channels-cline`。 - -## 对照后判定「不适用」的(附理由,不是"没做") - -| 模块 | 为什么不需要 | -|---|---| -| `account-pool` 并发写竞态 | 我们的落盘是**同步** tmp+rename(Lua 无异步),每次写序列化**当前** state。参考的缺陷是"异步后端 + 共享可变快照"的产物。**已加判据钉住**(已删账号不复活、新增不丢失、内存/磁盘不分叉) | -| `gemini-messages` SSE 末帧丢失 | 我们的 `bufio.Scanner` 对无尾换行的最后一行**照样产出**(已实测自证)。参考那是在 `while(!done)` 里按行处理所致 | -| `cline-rate-limit` 的 30 分钟策略冷却 | 我们**每账号一个源**(`channels--`)⇒ 某账号被拦只冷却它自己,下个请求自然换源。参考是"一个池里换号",才需要长冷却挡住同一轮里的连续重撞 | -| `message-shape` | DSH 宿主的消息形状迁移;我们直接透传 OpenAI 形状 | -| `loop-recovery` | 前提不存在(我们的内核没有循环守卫) | -| `reasoning-ladder` | 需要内核支持 `efforts` 字段(属内核改动,按纪律不做) | -| `retry-after.ts` | 参考那份实现**自己就是死代码**(grep 无消费者);我们的固定 30s 冷却有明确理由(429 是"太快"不是"坏了",走指数退避会让额度充沛的源被锁 30 分钟) | -| `image-budget` | 实际缩放委托给宿主的附件服务,我们无对应物 | - -## 运行环境的一条教训(不是代码缺陷) -全量 `go test` 曾出现 `TestConcurrentPluginAdapterUpdates` **挂 59 分钟**、 -看着像死锁。单独跑 **0.058s 通过** ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建 -缓存占满 100%**。教训:构建/测试挂住时先 `df -h /tmp`(`/` 空闲不代表 `/tmp` 够)。 diff --git a/docs/deployment.md b/docs/deployment.md index abea189..e8cf68b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -57,7 +57,7 @@ sudo journalctl -u modelrouter -f tar 包内只有:`modelrouter`、`config.example.yaml`、`modelrouter.service`、 `migrate-to-modelrouter.sh`、`adapters/`。 -**发行包不含业务插件**(`channels` / `billing` / `quota` / `usage` 等)。 +**发行包不含业务插件**(仓库只保留 `billing` / `usage` 两个与厂商无关的示例)。 它们保留在仓库中作为可选扩展,需要时手工放入 `plugin_dir`,网关不会自动安装、 覆盖或删除。这是有意的:插件属于运维资产,生命周期不与内核绑定。 @@ -72,7 +72,7 @@ key 打印在启动日志里。发行包不预置任何可用 key(历史上曾 - `master.key` 必须 0600。当前 unit 以 root 运行:降权到普通用户需要先把 `master.key` 交给该用户并统一目录属主,否则服务会"带着明文落盘继续跑", 失败只留一行日志——这是独立变更,不要和加固混在一起。 -- `plugin_dir` 由运维维护;插件自带的状态文件(如 `.channels-store.json`)也在其中。 +- `plugin_dir` 由运维维护;插件自带的状态文件也在其中。 ## 4. 从 llmsproxy 迁移 diff --git a/docs/features.md b/docs/features.md index 99d8d70..28d53af 100644 --- a/docs/features.md +++ b/docs/features.md @@ -10,7 +10,7 @@ Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换 一个标准 `OpenAI Chat Completions` 接口(`/v1/chat/completions` + `/v1/models`), 支持单次结算、SSE 流式、**AUTO 模型路由**与**多模态**透传。 -从 [HomeAgent](https://gitcode.com/JianFeeeee/HomeAgent) 的多源 LLM 适配层 +从上游多源 LLM 适配层 (`internal/agent/api/provider.go` + `internal/lua/adapters/*`)抽离而来并独立演进。 ## 特性 diff --git a/docs/git-workflow-en.md b/docs/git-workflow-en.md index 0ffea85..0aca7c6 100644 --- a/docs/git-workflow-en.md +++ b/docs/git-workflow-en.md @@ -134,7 +134,7 @@ git push origin vX.Y.Z make core-dist VERSION=X.Y.Z # 5. create GitCode Release + upload -# https://gitcode.com/JianFeeeee/ModelRouter/releases +# https://atomgit.com/HeNanMU-EEA/ModelRouter/releases # upload MUST use PUT --http1.1, otherwise the OBS callback fails and the # file never lands in the release; replacing artifacts = delete the tag # (release disappears with it) then recreate. diff --git a/docs/git-workflow.md b/docs/git-workflow.md index af8722b..96d38fc 100644 --- a/docs/git-workflow.md +++ b/docs/git-workflow.md @@ -124,7 +124,7 @@ git push origin vX.Y.Z make core-dist VERSION=X.Y.Z # 5. 创建 GitCode Release + 上传安装包 -# 见 https://gitcode.com/JianFeeeee/ModelRouter/releases +# 见 https://atomgit.com/HeNanMU-EEA/ModelRouter/releases # 上传注意:必须 PUT --http1.1,否则 OBS 回调失败文件不进列表; # 替换旧版附件 = 删 tag(release 随 tag 一起消失)后重建。 ``` diff --git a/docs/gui.md b/docs/gui.md index 2037bbc..65a2a9d 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -61,7 +61,7 @@ rpm 需系统 `rpmbuild`。每次打包后都会跑 `packaging/verify-dist.sh` - 构建镜像:`cmd/gui/docker/win-builder/Dockerfile`(`golang:1.25` + mingw-w64 + node + wine32/wine64 + 预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。 - LuaJIT 源码取自 gitcode 镜像(国内可达),github 兜底;Go 依赖走 `https://goproxy.cn`。 + LuaJIT 源码取自国内镜像,上游兜底;Go 依赖走 `https://goproxy.cn`。 - 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译 + electron-builder NSIS 全部在容器内执行(`USE_SYSTEM_WINE=true` 走镜像内的 wine);electron-builder 的 ~400MB 工具链缓存挂到 docker volume,二次构建增量复用。 diff --git a/docs/plugins.md b/docs/plugins.md index ce8df2a..1aa0c88 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -332,13 +332,13 @@ PUT /api/plugins/{name}/state 替换状态(admin) ### 5.4 定时任务(`PLUGIN.schedule`)—— 浏览器关着也会跑 -要求插件做“随时间发生”的事(自动签到、token 轮换、额度轮询)时,声明一个 +要求插件做“随时间发生”的事(定时刷新、token 轮换、指标采集)时,声明一个 调度表: ```lua plugin.schedule = { { fn = "refresh_token", every = 3600 }, -- 每小时(秒) - { fn = "daily_checkin", every = 86400, at = "03:30" }, -- 每天固定时刻(本地时间) + { fn = "daily_refresh", every = 86400, at = "03:30" }, -- 每天固定时刻(本地时间) { fn = "poll_quota", every = 300, jitter = 30 }, -- 首次触发加 0-30s 抖动 } function plugin.refresh_token() diff --git a/e2e/channels_device_e2e_test.go b/e2e/channels_device_e2e_test.go deleted file mode 100644 index 0b12c83..0000000 --- a/e2e/channels_device_e2e_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package e2e - -import ( - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" -) - -// TestChannelsDevicePollE2E 用**真二进制**跑通 channels 插件的 device-poll 登录: -// -// 发起登录(start_login) → 页面轮询(poll_login) 推进设备码换 token -// → 凭据入保险库 → add_source 把平台接成上游源 → /v1/models 里出现其模型 -// -// 这是对参考仓库 dsh-our-free-model「登录→领额度→接入源」形态的端到端对齐: -// 行为(HTTP 决策分支、会话推进、最终可路由)在真进程里验证,而不是单元桩。 -// -// 假上游模拟 MiniMax 的两个端点:设备码申请 + token 轮询(脚本化逐次响应)。 -func TestChannelsDevicePollE2E(t *testing.T) { - // ---- 假平台上游(loopback,白名单天然放行)---- - var tokenHits int32 - var tokenScript atomic.Value // []scriptStep - tokenScript.Store([]scriptStep{}) - plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/oauth2/device/code": - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"device_code":"DEV-E2E","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) - case "/oauth2/token": - i := int(atomic.AddInt32(&tokenHits, 1)) - 1 - script := tokenScript.Load().([]scriptStep) - w.Header().Set("Content-Type", "application/json") - if i >= len(script) { - w.WriteHeader(500) - fmt.Fprint(w, `{"error":"unexpected"}`) - return - } - if script[i].code != 0 && script[i].code != 200 { - w.WriteHeader(script[i].code) - } - fmt.Fprint(w, script[i].body) - case "/v1/models": - // 接入源后内核会探测可达;回一个 OpenAI 形状的模型列表 - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"object":"list","data":[{"id":"MiniMax-M3","object":"model"}]}`) - case "/v1/chat/completions": - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) - default: - w.WriteHeader(404) - } - })) - defer plat.Close() - - // ---- 真二进制 + 配置(带 plugin_dir,让 bundled channels 落地 + 授权可持久化)---- - dir := t.TempDir() - addr := mustAddr(t) - pluginDir := filepath.Join(dir, "plugins") // 不预建:SeedBundled 见空目录才落地内置插件 - cfg := "listen: " + addr + "\n" + - "gateway_keys:\n - sk-e2e-0001\n" + - "default_model: none\n" + - "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + - "plugin_dir: " + pluginDir + "\n" + - "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + - "sources: []\n" - cfgPath := filepath.Join(dir, "config.yaml") - if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { - t.Fatal(err) - } - bin := buildBinary(t) - g := startGatewayNoModels(t, bin, addr, cfgPath) - - call := func(method, path, body string) (int, map[string]interface{}) { - resp, raw := g.do(method, path, body, true) - var m map[string]interface{} - _ = json.Unmarshal([]byte(raw), &m) - return statusOf(resp), m - } - - // 1) 授权内核动作(运维批准 add_source 等)+ 把 minimax 端点指到假上游。 - st, m := call("PUT", "/api/plugins/channels/kernel", - `{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`) - if st != 200 { - t.Fatalf("授权内核动作失败: status=%d body=%v", st, m) - } - st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( - `{"platform":"minimax","base_url":%q,"device":{"code_url":%q,"token_url":%q}}`, - plat.URL+"/v1", plat.URL+"/oauth2/device/code", plat.URL+"/oauth2/token")) - if st != 200 { - t.Fatalf("set_endpoints 失败: status=%d body=%v", st, m) - } - - // 2) 发起 device-poll 登录:拿到会话 + user_code。 - st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"minimax"}`) - if st != 200 { - t.Fatalf("start_login status=%d body=%v", st, m) - } - sess, _ := m["session"].(map[string]interface{}) - if sess == nil || sess["user_code"] != "WXYZ-1234" { - t.Fatalf("start_login 未透传 user_code: %v", m) - } - sid, _ := sess["id"].(string) - if sid == "" { - t.Fatal("无会话 id") - } - - // 3) 第一次轮询:上游 pending → 会话仍 pending。 - tokenScript.Store([]scriptStep{{code: 400, body: `{"error":"authorization_pending"}`}}) - atomic.StoreInt32(&tokenHits, 0) - st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") - if m["state"] != "pending" { - t.Fatalf("轮询1 应 pending, 实得 %v", m) - } - - // 4) 第二次轮询:上游发 access_token → 会话 completed,凭据入库 + 接入源。 - tokenScript.Store([]scriptStep{{code: 200, body: `{"access_token":"MM-E2E-TOKEN","refresh_token":"MM-RF","nickname":"e2e-user"}`}}) - atomic.StoreInt32(&tokenHits, 0) - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") - if m["state"] == "completed" || m["state"] == "failed" { - break - } - time.Sleep(150 * time.Millisecond) - } - if m["state"] != "completed" { - t.Fatalf("轮询2 应 completed, 实得 %v", m) - } - if m["account"] != "e2e-user" { - t.Fatalf("account 应取 nickname, 实得 %v", m) - } - - // 5) 接入源的终局证据:/v1/models 里出现 channels-minimax 的模型。 - deadline = time.Now().Add(5 * time.Second) - var modelsBody string - for time.Now().Before(deadline) { - resp, raw := g.do("GET", "/v1/models", "", true) - modelsBody = raw - if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "MiniMax-M3") { - break - } - time.Sleep(150 * time.Millisecond) - } - if !strings.Contains(modelsBody, "MiniMax-M3") { - t.Fatalf("接入源后 /v1/models 未出现 MiniMax-M3: %s", modelsBody) - } -} - -type scriptStep struct { - code int - body string -} - -// startGatewayNoModels 与 startGateway 相同,但就绪探针不要求 /v1/models 返回 -// 非空:本用例启动时没有任何源,/v1/models 是空列表(200 即就绪)。 -func startGatewayNoModels(t *testing.T, bin, listen, cfgPath string) *gatewayUnderTest { - t.Helper() - return startGateway(t, bin, listen, cfgPath) -} - -var _ = net.Listen -var _ = io.Discard diff --git a/e2e/channels_flows_e2e_test.go b/e2e/channels_flows_e2e_test.go deleted file mode 100644 index f7597c7..0000000 --- a/e2e/channels_flows_e2e_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package e2e - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" -) - -// TestChannelsFlowsE2E 用真二进制补齐另外三条流程的端到端: -// -// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据) -// paste(opencode:直接 complete_login 贴 API key) -// device-poll 授权页(qoder:本地造 PKCE,把手动授权页给用户,再凭据完成) -// -// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。 -func TestChannelsFlowsE2E(t *testing.T) { - var authHits, regHits int32 - plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - // ---- Cline(WorkOS)---- - case "/user_management/authorize/device": - fmt.Fprint(w, `{"device_code":"CD-E2E","user_code":"CLNE-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) - case "/user_management/authenticate": - if atomic.AddInt32(&authHits, 1) == 1 { - w.WriteHeader(400) - fmt.Fprint(w, `{"error":"authorization_pending"}`) - return - } - fmt.Fprint(w, `{"access_token":"WO-E2E-AT","refresh_token":"WO-E2E-RT"}`) - case "/api/v1/auth/register": - atomic.AddInt32(®Hits, 1) - fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-E2E-AT","refreshToken":"CLINE-E2E-RT","userInfo":{"email":"dev@example.com"}}}`) - // ---- 上游推理面(opencode/qoder/cline 的 base_url 都指到这里)---- - case "/v1/models": - fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qmodel_38max","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`) - case "/v1/chat/completions": - fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) - default: - w.WriteHeader(404) - } - })) - defer plat.Close() - - dir := t.TempDir() - addr := mustAddr(t) - pluginDir := filepath.Join(dir, "plugins") - cfg := "listen: " + addr + "\n" + - "gateway_keys:\n - sk-e2e-0001\n" + - "default_model: none\n" + - "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + - "plugin_dir: " + pluginDir + "\n" + - "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + - "sources: []\n" - cfgPath := filepath.Join(dir, "config.yaml") - if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { - t.Fatal(err) - } - bin := buildBinary(t) - g := startGatewayNoModels(t, bin, addr, cfgPath) - - call := func(method, path, body string) (int, map[string]interface{}) { - resp, raw := g.do(method, path, body, true) - var m map[string]interface{} - _ = json.Unmarshal([]byte(raw), &m) - return statusOf(resp), m - } - - st, m := call("PUT", "/api/plugins/channels/kernel", - `{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`) - if st != 200 { - t.Fatalf("授权内核动作失败: status=%d body=%v", st, m) - } - - // ---------- ① Cline:WorkOS 设备码两步 ---------- - st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( - `{"platform":"cline","base_url":%q,"device":{"code_url":%q,"token_url":%q},"register":{"url":%q}}`, - plat.URL, plat.URL+"/user_management/authorize/device", - plat.URL+"/user_management/authenticate", plat.URL+"/api/v1/auth/register")) - if st != 200 { - t.Fatalf("set_endpoints(cline) 失败: status=%d body=%v", st, m) - } - st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"cline"}`) - if st != 200 { - t.Fatalf("cline start_login status=%d body=%v", st, m) - } - sess, _ := m["session"].(map[string]interface{}) - if sess == nil || sess["user_code"] != "CLNE-1234" { - t.Fatalf("cline start_login 未透传 user_code: %v", m) - } - sid, _ := sess["id"].(string) - - // 第一拍 pending(authorization_pending),第二拍 completed - atomic.StoreInt32(&authHits, 0) - st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") - if m["state"] != "pending" { - t.Fatalf("cline 轮询1 应 pending, 实得 %v", m) - } - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "") - if m["state"] == "completed" || m["state"] == "failed" { - break - } - time.Sleep(150 * time.Millisecond) - } - if m["state"] != "completed" { - t.Fatalf("cline 轮询2 应 completed, 实得 %v", m) - } - if m["account"] != "dev@example.com" { - t.Fatalf("cline account 应取 register userInfo.email, 实得 %v", m) - } - if atomic.LoadInt32(®Hits) != 1 { - t.Fatalf("register 应恰好调用一次, 实得 %d", regHits) - } - // 凭据必须是 register 的最终 token,不是 WorkOS token - var modelsBody string - deadline = time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - resp, raw := g.do("GET", "/v1/models", "", true) - modelsBody = raw - if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "stealth/space-bunny-alpha") { - break - } - time.Sleep(150 * time.Millisecond) - } - if !strings.Contains(modelsBody, "stealth/space-bunny-alpha") { - t.Fatalf("cline 接入后 /v1/models 未出现其模型: %s", modelsBody) - } - - // ---------- ② paste(opencode):complete 直接贴 key ---------- - st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( - `{"platform":"opencode","base_url":%q}`, plat.URL)) - if st != 200 { - t.Fatalf("set_endpoints(opencode) 失败: %v", m) - } - st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"opencode"}`) - if st != 200 { - t.Fatalf("opencode start_login status=%d", st) - } - if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "paste" { - t.Fatalf("opencode 会话 flow 应 paste: %v", m) - } - st, m = call("POST", "/api/plugins/http/channels/login/complete", - `{"platform":"opencode","credential":"OC-E2E-KEY"}`) - if st != 200 || m["ok"] != true { - t.Fatalf("opencode complete 失败: status=%d body=%v", st, m) - } - deadline = time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - resp, raw := g.do("GET", "/v1/models", "", true) - modelsBody = raw - if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "big-pickle") { - break - } - time.Sleep(150 * time.Millisecond) - } - if !strings.Contains(modelsBody, "big-pickle") { - t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody) - } - - // ---------- ③ device-poll(qoder):给授权页 + 凭据完成 ---------- - // qoder 曾走 manual(返回 manual_hint);现已改为真实的 PKCE 授权页流程 - // (本地造 challenge/verifier,服务端按 nonce 找回待授权会话)。 - st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf( - `{"platform":"qoder","base_url":%q}`, plat.URL)) - if st != 200 { - t.Fatalf("set_endpoints(qoder) 失败: %v", m) - } - st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"qoder"}`) - if st != 200 { - t.Fatalf("qoder start_login status=%d", st) - } - // 真流程必须给出授权页 URL(而不是「不支持自动登录」的 manual_hint), - // 且会话声明为 device-poll。 - if hint, _ := m["manual_hint"].(string); hint != "" { - t.Fatalf("qoder 已不再走 manual,不应返回 manual_hint: %q", hint) - } - if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "device-poll" { - t.Fatalf("qoder 会话 flow 应 device-poll: %v", m) - } - if authURL, _ := m["url"].(string); !strings.Contains(authURL, "challenge=") { - t.Fatalf("qoder 授权页 URL 应带 PKCE challenge,实得 %q", authURL) - } - st, m = call("POST", "/api/plugins/http/channels/login/complete", - `{"platform":"qoder","credential":"QD-E2E-KEY"}`) - if st != 200 || m["ok"] != true { - t.Fatalf("qoder complete 失败: status=%d body=%v", st, m) - } - deadline = time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - resp, raw := g.do("GET", "/v1/models", "", true) - modelsBody = raw - if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qmodel_38max") { - break - } - time.Sleep(150 * time.Millisecond) - } - if !strings.Contains(modelsBody, "qmodel_38max") { - t.Fatalf("qoder 接入后 /v1/models 未出现 qmodel_38max: %s", modelsBody) - } -} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 49df4c1..988a5ff 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -187,18 +187,20 @@ func writeConfig(t *testing.T, dir, listenAddr string, upstreams map[string]*moc sb.WriteString("runtime_file: " + filepath.Join(dir, "runtime.json") + "\n") sb.WriteString("sources:\n") order := []string{"good", "fallback", "image"} - prio := map[string]int{"good": 100, "fallback": 50} for _, name := range order { u, ok := upstreams[name] if !ok { continue } if name == "image" { - sb.WriteString(" - name: imagegen\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: flux-1\n kind: image\n priority: 80\n") + sb.WriteString(" - name: imagegen\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: flux-1\n kind: image\n") } else { - sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n priority: " + fmt.Sprint(prio[name]) + "\n") + sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n") } } + // AUTO 链现在是显式配置(25970a2 移除了 models.priority)。tier 越小越先试。 + sb.WriteString("auto:\n - model: good-m\n source: good\n tier: 1\n") + sb.WriteString(" - model: fallback-m\n source: fallback\n tier: 2\n") path := filepath.Join(dir, "config.yaml") if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil { t.Fatalf("write config: %v", err) diff --git a/internal/core/channels_account_adopt_test.go b/internal/core/channels_account_adopt_test.go deleted file mode 100644 index 5ce780f..0000000 --- a/internal/core/channels_account_adopt_test.go +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index 8289121..0000000 --- a/internal/core/channels_account_probe_test.go +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 072d6fb..0000000 --- a/internal/core/channels_authorize_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package core - -import ( - "net/url" - "regexp" - "strings" - "testing" - - "modelrouter/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_cover_test.go b/internal/core/channels_autoclaim_cover_test.go deleted file mode 100644 index 37d8df2..0000000 --- a/internal/core/channels_autoclaim_cover_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// ★ 自动领取的「今天已处理」记账(照参考 coversToday / shouldMarkToday)。 -// -// 真实缺陷(参考 2026-10-02 定位,每天静默漏 100 积分):Qoder 的活动 -// **每日 10:00(UTC+8)才刷新**,而自动签到在上午跑 —— 上午看到的是**昨天** -// 那条 CLAIMED,返回 `already-claimed`。若拿它记账,当天 10 点刷新后就再也 -// 不去领。 -// -// 修法不靠「给每个渠道配刷新时刻表」(必然漂移),而是让**产出方自己回答** -// 「我这条结果覆盖的是不是今天这一轮」(covers_today),记账方只数 -// 「有几条能证明今天已被处理」。 -func TestAutoClaimCountsCoversTodayNotKind(t *testing.T) { - _, ps := poolCore(t) - - // ① Qoder 在刷新前必须声明 covers_today=false(否则这条链就白修) - out, err := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{ - "refreshed": false, - }) - if err != nil { - t.Fatalf("探针: %v", err) - } - j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) - if j == nil || j["kind"] == nil { - t.Fatalf("探针应返回 kind,实得 %#v", out) - } - if j["covers_today"] != false { - t.Errorf("刷新前必须声明 covers_today=false(这正是漏领的根因),实得 %v", j["covers_today"]) - } - if j["kind"] == "already-claimed" { - t.Error("刷新前**绝不能**报 already-claimed(那会被记账成「今天已处理」)") - } - - // ② 刷新后:正常报已领,且 covers_today=true - out2, _ := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{ - "refreshed": true, - }) - j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{}) - if j2["covers_today"] != true { - t.Errorf("刷新后应声明 covers_today=true,实得 %v", j2["covers_today"]) - } - - // ③ 记账判据必须**只看 covers_today**,不能用 kind 反推 - //(`already-claimed` 天然有歧义 —— 可能就是昨天那一轮的痕迹) - cases := []struct { - name string - kind string - cov interface{} - want bool - }{ - {"刷新前的 already-claimed 不得算已处理", "already-claimed", false, false}, - {"刷新后的 already-claimed 算已处理", "already-claimed", true, true}, - {"success 算已处理", "success", true, true}, - {"covers_today 缺省为 true", "claimed", nil, true}, - {"inactive 不算已处理", "inactive", true, false}, - {"failed 不算已处理", "failed", true, false}, - } - for _, c := range cases { - o, err := ps.Call("channels", "__claim_covers_probe", map[string]interface{}{ - "kind": c.kind, "covers_today": c.cov, - }) - if err != nil { - t.Fatalf("%s: %v", c.name, err) - } - oj, _ := o.(map[string]interface{})["json"].(map[string]interface{}) - if got, _ := oj["covers"].(bool); got != c.want { - t.Errorf("%s: 应 %v,实得 %v", c.name, c.want, got) - } - } -} - -// 调度频率:一天只跑一次会让「刷新时刻晚于执行时刻」的平台整天漏领。 -func TestAutoClaimScheduledMoreThanOnceDaily(t *testing.T) { - _, ps := poolCore(t) - out, err := ps.Call("channels", "__schedule_probe", nil) - if err != nil { - t.Fatalf("探针: %v", err) - } - j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) - every, _ := j["auto_claim_every"].(float64) - if every <= 0 { - t.Fatalf("找不到 auto_claim 的调度间隔,实得 %v", j) - } - // 必须显著小于一天:活动刷新时刻各平台不同(Qoder 是每日 10:00 UTC+8), - // 一天只跑一次就必然有平台漏领。 - if every >= 86400 { - t.Errorf("auto_claim 间隔应远小于一天(否则刷新晚的平台整天漏领),实得 %.0f 秒", every) - } - if j["first_due_now"] != true { - t.Error("auto_claim 应 first_due_now(重启立即补跑,不等一个周期)") - } - // 已覆盖今天的平台必须靠记账跳过,否则会重复打上游 - if !strings.Contains(toString(j["has_coverage_key"]), "true") { - t.Error("需要「今天已覆盖」的持久化记账键") - } -} - -func toString(v interface{}) string { - if b, ok := v.(bool); ok { - if b { - return "true" - } - return "false" - } - return "" -} diff --git a/internal/core/channels_autoclaim_test.go b/internal/core/channels_autoclaim_test.go deleted file mode 100644 index 84d7b7e..0000000 --- a/internal/core/channels_autoclaim_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// 自动签到的**能力表**必须与参考真值一致 —— 这张表是 auto_claim 的唯一依据, -// 错了就是"静默少领"或"每次调用都报自相矛盾的错"。 -// -// 实测踩到的三处偏差(本轮修正): -// -// buddy 我们写 claim=true,但 buddy 既无 checkin 又无 claim_fn → -// 每次调用返回"不支持每日签到(平台未声明 checkin)"(表说支持、 -// 函数说不支持)。参考里 buddy **没有** claim。 -// raccoon 我们声明空表 → 白丢一个可领渠道。参考有:桌面端登录奖励 -// POST /api/web/desktop/v1/login/points/grant(每号一次)。 -// zcode 有签到,但每次领取现场产阿里云 captcha(headful Chromium, -// 按设备限流 150 次/小时)→ 参考把它列进 auto-checkin 的**排除表**。 -// 自动跳过,手动仍可领。 -func TestAutoClaimCapabilityTableMatchesReference(t *testing.T) { - c, ps := poolCore(t) - _ = c - - // 参考真值(逐个 grep 参考仓的 claim 导出确认) - wantClaim := map[string]bool{ - "trae": true, "minimax": true, "lobsterai": true, "qoder": true, - "loomy": true, "raccoon": true, "codearts": true, - "cline": false, "gemini": false, "zcode": true, "buddy": false, - } - // 自动签到必须跳过的(领取有代价) - wantAutoExclude := map[string]bool{"zcode": true} - - for plat, want := range wantClaim { - t.Run("claim_"+plat, func(t *testing.T) { - got := creditsCapability(t, ps, plat) - if got == nil { - t.Fatalf("%s 无能力声明", plat) - } - if has := got["claim"] == true; has != want { - t.Errorf("%s claim = %v,参考为 %v", plat, has, want) - } - if got, ok := got["auto_exclude"].(bool); ok != wantAutoExclude[plat] { - t.Errorf("%s auto_exclude = %v,应为 %v", plat, got, wantAutoExclude[plat]) - } - }) - } - - // 关键一致性:声称 claim=true 的平台**必须**真的可领 - //(有 checkin 表或有 claim_fn)—— 否则就是 buddy 那种自相矛盾。 - for plat, want := range wantClaim { - if !want { - continue - } - m, err := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": plat}) - if err != nil { - t.Fatalf("%s: %v", plat, err) - } - w, _ := m.(map[string]interface{}) - j, _ := w["json"].(map[string]interface{}) - if j == nil { - t.Fatalf("%s 无返回", plat) - } - if j["capable"] != true { - t.Errorf("%s 声称 claim=true 但不可领(%v)—— 会出现「表说支持、"+ - "函数说不支持」的自相矛盾", plat, j["reason"]) - } - } - // 反向:不声称 claim 的平台必须被判为不可领 - m, _ := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": "buddy"}) - w, _ := m.(map[string]interface{}) - j, _ := w["json"].(map[string]interface{}) - if j != nil && j["capable"] == true { - t.Error("buddy 不该被判为可领(它没有 claim)") - } -} - -// 自动签到的**调度配置**必须覆盖两项需求:领取 + 模型获取。 -// -// auto_claim 走调度器(不是 HTTP export),故断言调度声明。 -func TestAutoClaimScheduledWithModelRefresh(t *testing.T) { - b, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - for _, want := range []struct{ name, needle string }{ - {"每日领取", `fn = "auto_claim"`}, - {"模型目录自动获取", `fn = "refresh_models"`}, - {"凭据自动续期", `fn = "refresh_creds"`}, - {"启动即补跑", `first_due_now = true`}, - } { - if !strings.Contains(b, want.needle) { - t.Errorf("调度表缺 %s(%s)", want.name, want.needle) - } - } - // 模型刷新间隔照参考 MODEL_REFRESH_INTERVAL_MS(2 小时) - if !strings.Contains(b, `fn = "refresh_models", every = 7200`) { - t.Error("refresh_models 间隔应为 7200(参考 2h)") - } - // 按**行**判断(固定字符窗会溢到下一行,误报) - lineOf := func(fn string) string { - for _, ln := range strings.Split(b, "\n") { - if strings.Contains(ln, `fn = "`+fn+`"`) { - return ln - } - } - return "" - } - // auto_claim **现在**也 first_due_now,且间隔远小于一天。 - // - // ★ 这里改过一次,原因值得记下:原判据写的是「auto_claim 是每日任务, - // 不该 first_due_now(重启就重领是错的)」—— 那个假设**本身是缺陷的一部分**。 - // 活动刷新时刻**各平台不同**(Qoder 是每日 10:00 UTC+8),一天只跑一次 - // 必然有平台整天漏领(参考实测:每天静默漏 100 积分)。修法有两半: - // ① 频率提高 + first_due_now(任何时刻重启都能补上); - // ② 「今天已覆盖」靠 covers_today 记账跳过(**这才是防重领的正确机制**, - // 而不是靠"一天只跑一次"来碰巧避开)。 - // 只做 ② 不做 ① 仍会漏领;只做 ① 不做 ② 会重复打上游。 - if ln := lineOf("auto_claim"); !strings.Contains(ln, "first_due_now = true") { - t.Errorf("auto_claim 应 first_due_now(否则重启后当天漏领):%s", ln) - } - // 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈) - for _, fn := range []string{"refresh_models", "refresh_creds"} { - ln := lineOf(fn) - if ln == "" { - continue - } - if !strings.Contains(ln, "first_due_now = true") { - t.Errorf("%s 应声明 first_due_now(参考点名的重启自愈缺陷):%s", fn, ln) - } - } - // auto_exclude 必须挡住 zcode(领取有代价:headful Chromium) - if !strings.Contains(b, "auto_exclude = true") { - t.Error("zcode 应标记 auto_exclude(照参考 isAutoCheckinExcluded)") - } - if i := strings.Index(b, `if cap.auto_exclude then`); i < 0 { - t.Error("auto_claim 必须尊重 auto_exclude(否则会拉起浏览器)") - } -} - -// creditsCapability 读插件的能力表条目。 -func creditsCapability(t *testing.T, ps interface { - Call(string, string, map[string]interface{}) (interface{}, error) -}, plat string) map[string]interface{} { - t.Helper() - m, err := ps.Call("channels", "credits_capability", map[string]interface{}{"platform": plat}) - if err != nil { - t.Fatalf("credits_capability(%s): %v", plat, err) - } - w, _ := m.(map[string]interface{}) - j, _ := w["json"].(map[string]interface{}) - if j == nil { - return nil - } - if cap, ok := j["capability"].(map[string]interface{}); ok { - return cap - } - return nil -} diff --git a/internal/core/channels_balance_tier_test.go b/internal/core/channels_balance_tier_test.go deleted file mode 100644 index 25bf422..0000000 --- a/internal/core/channels_balance_tier_test.go +++ /dev/null @@ -1,122 +0,0 @@ -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_buddy_catalog_filter_test.go b/internal/core/channels_buddy_catalog_filter_test.go deleted file mode 100644 index 62da248..0000000 --- a/internal/core/channels_buddy_catalog_filter_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// ★ 远端模型目录混着「不可调用」的条目,必须过滤掉。 -// -// 真实缺陷(生产实测):buddy 源声明了 24 个模型,其中 -// -// nes-gf / codewise-completions / codewise-rewrite / codewise-jump / -// codewise-default-model-v2 / hunyuan-image-alpha / auto -// -// 全部调不通(`glm-5.1` 也是 404)。用户选中就报错,而错误文案指向**模型**, -// 极易被误判成"这个模型坏了"。 -// -// 参考实现(buddy.ts 的 isChatModel / isAutoSelectAlias)记录了**四类**判据, -// 且都是实测依据: -// -// ① 自动选择别名 auto/default(**只按字面量**,前缀匹配会误伤 default-model) -// ② 补全/NES 专用前缀 nes- / completion- / codewise-,或 supportsExtra=true -// (参考实测 codewise-default-model-v2 → `code 11102 service info not found`) -// ③ maxOutputTokens >0 且 ≤256(补全用途;对话模型普遍 ≥24000) -// ④ 带 text-to-image 标签(如 hunyuan-image-alpha) -func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - - // 判据一:四类规则必须都在源码里(防止有人"精简"掉其中一条) - for _, rule := range []string{ - `id == "auto" or id == "default"`, // ① - `id:sub(1, 4) == "nes-"`, // ② - `id:sub(1, 11) == "completion-"`, // ② - `id:sub(1, 9) == "codewise-"`, // ② - `m.supportsExtra == true`, // ② - `mo > 0 and mo <= 256`, // ③ - `t == "text-to-image"`, // ④ - } { - if !strings.Contains(s, rule) { - t.Errorf("buddy 目录过滤缺少规则:%s\n"+ - "(这四类都是参考用真实请求验证过的「列出来就会让用户报错」的条目,"+ - "删任何一条都会把不可调用模型放回选择器)", rule) - } - } - - // 判据二:`default-model` / `fast-model` 必须**不在**被过滤的形态里。 - // 参考注释明确警告:只过滤 auto/default 两个字面量,不做前缀匹配 —— - // 前缀会误伤这两个国际版别名(它们被 craft agent 引用,是官方推荐入口)。 - if strings.Contains(s, `id:sub(1, 7) == "default"`) { - t.Error("buddy 过滤把 default 做成了**前缀**匹配 ⇒ 会误伤 default-model(参考点名警告)") - } - t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名") -} - -// ★ 已实测**不可调用**的模型必须也从目录里排除 —— 包括 agentRef 例外路径。 -// -// 真实缺陷:`hy4-preview-x` 被服务端 agents 引用,于是 agentRef 例外把它放回 -// 目录;而它对上游**恒回 11102**(`model service info not found`,加/不加 -// reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。 -// 用户选中就 400,错误文案指向模型 ⇒ 极易误判"模型坏了"。 -// -// 参考用**白名单重建**(product.ts 的兜底表里没有它)达到同样效果。 -func TestBuddyCatalogDropsKnownUncallable(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - - if !strings.Contains(s, "local NOT_CALLABLE = {") { - t.Fatal("必须有已实测不可调用的排除表(等价于参考的白名单)") - } - if !strings.Contains(s, `["hy4-preview-x"] = true`) { - t.Error("hy4-preview-x 实测恒 11102,必须排除") - } - // 闸门必须生效在**所有**入口 —— 尤其是 agentRef 例外之后 - if !strings.Contains(s, "if NOT_CALLABLE[id] then return false end") { - t.Error("NOT_CALLABLE 闸门必须在 buddyIsChatModel 内(覆盖 models 与 agentRef 两条路径)") - } - t.Log("✓ 不可调用模型已排除,且 agentRef 例外也过闸") -} diff --git a/internal/core/channels_callback_loopback_test.go b/internal/core/channels_callback_loopback_test.go deleted file mode 100644 index 10ecde3..0000000 --- a/internal/core/channels_callback_loopback_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package core - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "modelrouter/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 deleted file mode 100644 index eee1342..0000000 --- a/internal/core/channels_capability_parse_test.go +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 1753329..0000000 --- a/internal/core/channels_capability_test.go +++ /dev/null @@ -1,86 +0,0 @@ -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_clear_credentialless_test.go b/internal/core/channels_clear_credentialless_test.go deleted file mode 100644 index d9e234d..0000000 --- a/internal/core/channels_clear_credentialless_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package core - -import ( - "testing" - - "modelrouter/internal/lua" -) - -// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。 -// -// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、 -// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数, -// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用 -// (codearts 10 + trae 2 全是占位)。 -// -// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。 -func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) { - h := newAccountCleanupHarness(t) - - // ① 默认 dry_run:必须**不删**任何东西 - pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{}) - if err != nil { - t.Fatal(err) - } - preJ := jsonOf(t, pre) - if preJ["dry_run"] != true { - t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"]) - } - // 夹具放了 4 个 codearts(3 空壳 + 1 有凭据) - if got := len(accountIDs(t, h, "codearts")); got != 4 { - t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got) - } - // 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5 - if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 { - t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"]) - } - if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 { - t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"]) - } - - // ② 真执行:删无凭据的,**保留有凭据的** - if _, err := h.ps.Call("channels", "account_clear_credentialless", - map[string]interface{}{"dry_run": false}); err != nil { - t.Fatal(err) - } - if got := len(accountIDs(t, h, "codearts")); got != 1 { - t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got) - } - // ★ 关键:留下的必须是**有凭据的那个** - left := accountIDs(t, h, "codearts") - if len(left) != 1 || left[0] != "withcred" { - t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left) - } - // 有凭据的账号其凭据也必须仍在(不能被误删) - if !h.hasCredential("withcred") { - t.Fatal("有凭据账号的凭据被误删了") - } - t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好") -} - -// 指定平台时只动那个平台,别的不碰。 -func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) { - h := newAccountCleanupHarness(t) - - if _, err := h.ps.Call("channels", "account_clear_credentialless", - map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil { - t.Fatal(err) - } - // codearts 被清(剩 1),trae 的 2 个占位**不该被动** - if got := len(accountIDs(t, h, "codearts")); got != 1 { - t.Fatalf("codearts 应剩 1,实得 %d", got) - } - if got := len(accountIDs(t, h, "trae")); got != 2 { - t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got) - } - t.Log("✓ 平台范围隔离正确") -} - -// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。 -func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) { - h := newAccountCleanupHarness(t) - res, err := h.ps.Call("channels", "account_clear_credentialless", - map[string]interface{}{"platform": "no-such-platform", "dry_run": false}) - if err != nil { - t.Fatal(err) - } - j := jsonOf(t, res) - if j["error"] == nil { - t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j) - } - // 且什么都没删 - if got := len(accountIDs(t, h, "codearts")); got != 4 { - t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got) - } -} - -// ── harness ── - -type accountCleanupHarness struct { - t *testing.T - ps *lua.Plugins -} - -func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness { - t.Helper() - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - h := &accountCleanupHarness{t: t, ps: ps} - // 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位 - for _, id := range []string{"empty1", "empty2", "empty3"} { - h.add("codearts", id, "") - } - h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`) - h.add("trae", "tempty1", "") - h.add("trae", "tempty2", "") - return h -} - -func (h *accountCleanupHarness) add(platform, id, cred string) { - h.t.Helper() - args := map[string]interface{}{"platform": platform, "account_id": id} - if cred != "" { - args["credential"] = cred - } - if _, err := h.ps.Call("channels", "account_add", args); err != nil { - h.t.Fatalf("account_add(%s/%s): %v", platform, id, err) - } -} - -func (h *accountCleanupHarness) hasCredential(accountID string) bool { - h.t.Helper() - res, err := h.ps.Call("channels", "account_list", - map[string]interface{}{"platform": "codearts"}) - if err != nil { - h.t.Fatal(err) - } - for _, a := range accountsOf(h.t, res) { - if a["id"] == accountID { - return a["has_credential"] == true - } - } - return false -} - -// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。 -func jsonOf(t *testing.T, got interface{}) map[string]interface{} { - t.Helper() - m, ok := got.(map[string]interface{}) - if !ok { - t.Fatalf("返回值不是对象: %#v", got) - } - j, ok := m["json"].(map[string]interface{}) - if !ok { - t.Fatalf("返回值缺 json 层: %#v", m) - } - return j -} - -// accountsOf 取 account_list 返回的账号数组。 -func accountsOf(t *testing.T, got interface{}) []map[string]interface{} { - t.Helper() - j := jsonOf(t, got) - arr, _ := j["accounts"].([]interface{}) - out := make([]map[string]interface{}, 0, len(arr)) - for _, e := range arr { - if m, ok := e.(map[string]interface{}); ok { - out = append(out, m) - } - } - return out -} - -// accountIDs 列某平台的账号 id。 -func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string { - t.Helper() - res, err := h.ps.Call("channels", "account_list", - map[string]interface{}{"platform": platform}) - if err != nil { - t.Fatalf("account_list(%s): %v", platform, err) - } - var out []string - for _, a := range accountsOf(t, res) { - if id, ok := a["id"].(string); ok { - out = append(out, id) - } - } - return out -} - -// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。 -// -// 反例(实测踩到,我自己写的):照其它 handler 写 -// -// `if args.platform == nil then args = req end` -// -// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成 -// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成 -// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。 -// -// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。 -func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) { - h := newAccountCleanupHarness(t) - - // 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态) - res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless", - &lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`}) - if err != nil { - t.Fatal(err) - } - if res.Status != 200 { - t.Fatalf("status=%d body=%v", res.Status, res.Body) - } - j, _ := res.JSON.(map[string]interface{}) - if j["dry_run"] == true { - t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j) - } - if j["deleted"] == nil { - t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j) - } - // 全平台的 5 个空壳都被删(codearts 3 + trae 2) - if got := len(accountIDs(t, h, "codearts")); got != 1 { - t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got) - } - if got := len(accountIDs(t, h, "trae")); got != 0 { - t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got) - } - t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"]) -} diff --git a/internal/core/channels_cline_adapter_test.go b/internal/core/channels_cline_adapter_test.go deleted file mode 100644 index a2855bf..0000000 --- a/internal/core/channels_cline_adapter_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// Cline 的 429/402 话术与冷却时长(照参考 cline-rate-limit.ts)。 -// -// ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",用户看到 -// 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就从当下重新计一小时 -// ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 —— -// 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游。 -// -// 抓包真值(参考实测,no-retry: true —— 没有任何 retry-after 头): -// -// HTTP 429 -// {"error":{"code":"INFERENCE_CAP_ERROR", -// "message":"Error 429: Daily free limit reached on model -// deepseek/deepseek-v4-flash. Try again in 19h 39m"}} -func TestClineAdapterParsesProseWait(t *testing.T) { - c, _ := poolCore(t) - vm := c.VM() - - // ① 当日免费额度用尽:必须被归一成**内核已认的中立短语** - //(否则内核按 30 秒短冷却处理 ⇒ 每 30 秒猛敲上游) - body := `{"error":{"code":"INFERENCE_CAP_ERROR","message":` + - `"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 19h 39m"}}` - out, ok, err := vm.TransformError("channels-cline", 429, body) - if err != nil || !ok { - t.Fatalf("transform_error: err=%v ok=%v", err, ok) - } - if !strings.Contains(out, "额度已用完") { - t.Errorf("必须归一成内核已认的「额度已用完」(否则不会被当成额度耗尽处理),实得 %q", out) - } - // 真实等待时长必须出现在话术里(19h39m ≈ 19 小时) - if !strings.Contains(out, "19") { - t.Errorf("必须把真实等待时长写进话术(用户才知道要等多久),实得 %q", out) - } - if !strings.Contains(out, "重试无用") { - t.Errorf("当日额度用尽要明确告知重试无用(否则用户以为等一会就好),实得 %q", out) - } - // ⚠️ 绝不能出现参考踩过的「1 小时」误导 - if strings.Contains(out, "1 小时") && !strings.Contains(out, "19") { - t.Errorf("不得退回快照式的「1 小时」误导,实得 %q", out) - } - - // ② 时长 token 必须**累加**(`1h 30m` = 90 分钟),不能只取第一个 —— - // 通过真实的 transform_error 路径验证(不自己重算解析器)。 - acc, _, _ := vm.TransformError("channels-cline", 429, - `{"error":{"message":"Daily free limit reached. Try again in 1h 30m"}}`) - if !strings.Contains(acc, "1 小时 30 分") { - t.Errorf("`1h 30m` 必须累加成人话时长(1 小时 30 分),实得 %q", acc) - } - // 秒级也要能解析(否则秒级限流会退化成通用文案) - secs, _, _ := vm.TransformError("channels-cline", 429, - `{"error":{"message":"Rate limited. Try again in 45s"}}`) - if !strings.Contains(secs, "分") && !strings.Contains(secs, "秒") { - t.Errorf("秒级时长应被解析并说出,实得 %q", secs) - } - - // ③ 402:等待不会恢复,必须说清楚要充值(不能只说"限流") - out402, _, _ := vm.TransformError("channels-cline", 402, - `{"error":{"message":"Insufficient credits"}}`) - if !strings.Contains(out402, "充值") { - t.Errorf("402 应明确提示需充值(等待无用),实得 %q", out402) - } - - // ④ 普通 429 带时长:如实说,但不谎报成额度耗尽 - out429, _, _ := vm.TransformError("channels-cline", 429, - `{"error":{"message":"Rate limited. Try again in 30s"}}`) - if strings.Contains(out429, "额度已用完") { - t.Errorf("普通限流不得谎报成额度耗尽(那会让内核长时间冷却一个健康源),实得 %q", out429) - } - if !strings.Contains(out429, "限流") { - t.Errorf("普通 429 应说成限流,实得 %q", out429) - } - - // ⑤ 多种外壳都要认(`{error:"…"}` 字符串 / `{msg}` / 非 JSON 纯文本) - //(只认一种会在别的形态下静默丢掉整条信息) - for _, shell := range []string{ - `{"error":"Daily free limit reached. Try again in 1h"}`, - `{"msg":"Daily free limit reached. Try again in 1h"}`, - `Daily free limit reached. Try again in 1h`, - } { - o, _, _ := vm.TransformError("channels-cline", 429, shell) - if !strings.Contains(o, "额度已用完") { - t.Errorf("外壳 %q 未被识别为额度耗尽,实得 %q", shell, o) - } - } -} diff --git a/internal/core/channels_codearts_adapter_test.go b/internal/core/channels_codearts_adapter_test.go deleted file mode 100644 index f04ad3c..0000000 --- a/internal/core/channels_codearts_adapter_test.go +++ /dev/null @@ -1,176 +0,0 @@ -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) - // ★ 一个通道 = 一个源:源名是 `channels-codearts`,**不含账号 id**。 - // 按账号 id 找会永远失败 —— 这正是"加账号了但测试说没出源"的假故障。 - sm, ok := srcHasAccount(srcs, "codearts") - 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 nm, _ := sm["name"].(string); nm != "channels-codearts" { - t.Errorf("通道源名应为 channels-codearts,实得 %q(账号 id=%s)", nm, aid) - } - 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_catalog_test.go b/internal/core/channels_codearts_catalog_test.go deleted file mode 100644 index f388c76..0000000 --- a/internal/core/channels_codearts_catalog_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package core - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "modelrouter/internal/lua" -) - -// ★ codearts 的模型目录必须从**两个**签名端点拉取并合并。 -// -// 真实缺口(本轮发现):codearts 有 10 个可用账号,但目录里只有硬编码的 -// 1 个模型 "CodeArts-Pangu"。参考实现(fetchCodeArtsRemoteModels)打的是: -// -// ① opengw gateway/config → result.models(benefit 模型) -// ② snap-access /v1/model/builtin → builtinModels(常规模型) -// -// 两者都**必须 AK/SK 签名**,而我们的 refreshRemoteModels 此前只支持 Bearer -// ⇒ 这两个端点从来没有被打过。 -// -// 判据看**两个端点的真实命中**(各自拉到自己的字段名),而不是"目录非空" -// —— 后者只要兜底表在就恒真。 -type codeartsCatalogHarness struct { - t *testing.T - ps *lua.Plugins - mu sync.Mutex - hits map[int]int // 端点序号 → 命中次数 - gateway string - builtin string - authSeen []string -} - -func newCodeartsCatalogHarness(t *testing.T) *codeartsCatalogHarness { - t.Helper() - h := &codeartsCatalogHarness{t: t, hits: map[int]int{}} - - mk := func(idx int, body string) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.mu.Lock() - h.hits[idx]++ - // ★ 必须看到 AK/SK 签名的三个特征头,否则等于没签名 - authSeen := []string{ - r.Header.Get("X-Sdk-Date"), - r.Header.Get("X-Sdk-Content-Sha256"), - r.Header.Get("Authorization"), - } - h.authSeen = append(h.authSeen, strings.Join(authSeen, "|")) - h.mu.Unlock() - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, body) - })) - } - // ① gateway:benefit 模型在 result.models - gw := mk(0, `{"result":{"models":[{"model_id":"glm-5.3-flash"},{"model_id":"deepseek-v4-flash"}]}}`) - // ② snap builtin:常规模型在 builtinModels(**对象**,键为序号) - bi := mk(1, `{"count":2,"builtinModels":{"0":{"model_id":"GLM-5.2"},"1":{"model_id":"openpangu-2.0-pro"}}}`) - t.Cleanup(gw.Close) - t.Cleanup(bi.Close) - h.gateway, h.builtin = gw.URL, bi.URL - - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - h.ps = ps - - // 把两个端点都指到假上游(顺序与平台配置一致) - if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "codearts", - "models_remote": map[string]interface{}{ - "endpoint0": h.gateway, - "endpoint1": h.builtin, - }, - }); err != nil { - t.Fatalf("set_endpoints: %v", err) - } - // 塞一个 AK/SK 账号(签名分支需要凭据,否则会走 "无可用 AK/SK 凭据") - if _, err := ps.Call("channels", "account_add", map[string]interface{}{ - "platform": "codearts", - "id": "cat-test", - "credential": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST",` + - `"security_token":"STTEST","user_name":"cat"}`, - }); err != nil { - t.Fatalf("account_add: %v", err) - } - return h -} - -func TestCodeArtsCatalogPullsBothSignedEndpoints(t *testing.T) { - h := newCodeartsCatalogHarness(t) - - if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil { - t.Fatalf("model_refresh: %v", err) - } - - h.mu.Lock() - hits := map[int]int{} - for k, v := range h.hits { - hits[k] = v - } - authSeen := append([]string(nil), h.authSeen...) - h.mu.Unlock() - - // ① 两个端点都必须被命中(只打一个是真实缺口) - if hits[0] == 0 { - t.Fatal("gateway/config 端点从未被调用 —— benefit 模型(glm-5.3-flash 等)永远拿不到") - } - if hits[1] == 0 { - t.Fatal("snap /v1/model/builtin 端点从未被调用 —— 常规模型(GLM-5.2 / openpangu)永远拿不到") - } - // ② 每个请求都必须带 AK/SK 签名特征头(Bearer 打不通这两个端点) - for i, seen := range authSeen { - parts := strings.Split(seen, "|") - if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { - t.Fatalf("第 %d 个请求缺签名头(X-Sdk-Date/Content-Sha256/Authorization):%q", i+1, seen) - } - if !strings.Contains(parts[2], "SDK-HMAC-SHA256") { - t.Fatalf("Authorization 不是华为 SDK-HMAC-SHA256 形态,实得 %q", parts[2]) - } - } - - // ③ 合并结果必须同时含两个端点的模型(字段名不同:model_id,两边都要认) - got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"}) - if err != nil { - t.Fatal(err) - } - ids := modelIDs(t, got) - joined := strings.Join(ids, ",") - for _, want := range []string{"glm-5.3-flash", "GLM-5.2", "openpangu-2.0-pro"} { - if !strings.Contains(joined, want) { - t.Fatalf("合并目录缺 %s(两个端点必须**合并**而非只用其一):%v", want, ids) - } - } - t.Logf("✓ 两端点各命中 %d/%d 次,合并得到 %d 个模型", hits[0], hits[1], len(ids)) -} - -// 兜底表在远端失败时必须仍然可用(不能因为加了目录就让平台变空)。 -func TestCodeArtsCatalogKeepsFallbackOnFailure(t *testing.T) { - h := newCodeartsCatalogHarness(t) - // 让两个端点都 500 - dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(500) - })) - defer dead.Close() - if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "codearts", - "models_remote": map[string]interface{}{ - "endpoint0": dead.URL, "endpoint1": dead.URL, - }, - }); err != nil { - t.Fatal(err) - } - if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil { - t.Fatalf("远端失败不该让 model_refresh 报错: %v", err) - } - got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"}) - if err != nil { - t.Fatal(err) - } - ids := modelIDs(t, got) - if len(ids) == 0 { - t.Fatal("远端全失败时目录变空了 —— 兜底表必须补位(宁可留旧目录也别让平台变空)") - } - t.Logf("✓ 远端失败时兜底目录仍有 %d 个模型: %v", len(ids), ids) -} diff --git a/internal/core/channels_codearts_oauth_flow_test.go b/internal/core/channels_codearts_oauth_flow_test.go deleted file mode 100644 index f2851b0..0000000 --- a/internal/core/channels_codearts_oauth_flow_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package core - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "modelrouter/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 deleted file mode 100644 index e8172f1..0000000 --- a/internal/core/channels_codearts_oauth_test.go +++ /dev/null @@ -1,112 +0,0 @@ -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_pem_test.go b/internal/core/channels_codearts_pem_test.go deleted file mode 100644 index ddd2207..0000000 --- a/internal/core/channels_codearts_pem_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package core - -import ( - "encoding/base64" - "encoding/json" - "os" - "strings" - "testing" -) - -// codearts JWK→PEM 重建判据。 -// -// 背景(真实缺陷,2026-10-19 用户报「华为云源用不了」): -// -// 华为云 STS 的 security_token 只活 1 小时,续期(refresh_token 换新)需要 -// DPoP 请求签名。签名走 `codeartsDpopSign(key, ...)`,它要求 `key.pem` 是 -// **PEM 字符串**(内部用 openssl 命令行签)。而持久化的凭据里**只有** -// `dpop_private_key_jwk`(私钥的 JWK)—— PEM 是登录时的临时文件,没保存。 -// -// 于是续期必须先从 JWK 重建 PEM。这条判据钉住"重建出来的 PEM 真能被 openssl -// 接受并签出有效的 ES256 JWS",而不是"函数返回了非空字符串"。 -// -// 为什么必须真签名:DER 结构错、长度字段错、base64 换行错,都会得到 -// **非空但无效**的 PEM。只断言非空等于什么都没验。 -func TestCodeartsRebuildsPemFromJwkAndSigns(t *testing.T) { - // 一个结构合法的 P-256 私钥 JWK(生产凭据的真实形状:三个字段都是 - // 32 字节 base64url)。用固定值而非随机生成,是为了让失败可复现。 - jwk := map[string]any{ - "crv": "P-256", - "kty": "EC", - "d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U", - "x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4", - "y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM", - } - - _, ps := poolCore(t) - out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": jwk}) - if err != nil { - t.Fatalf("探针调用失败: %v", err) - } - wrapped, _ := out.(map[string]any) - inner, _ := wrapped["json"].(map[string]any) - if inner == nil { - t.Fatalf("探针返回缺 json 包装: %#v", out) - } - if e, _ := inner["error"].(string); e != "" { - t.Fatalf("JWK→PEM 重建失败: %s", e) - } - - // ① PEM 形状必须是 openssl 认的 SEC1 EC PRIVATE KEY - pem, _ := inner["pem"].(string) - if !strings.HasPrefix(pem, "-----BEGIN EC PRIVATE KEY-----") { - t.Fatalf("PEM 头部不对: %q", firstLine(pem)) - } - if !strings.Contains(pem, "-----END EC PRIVATE KEY-----") { - t.Fatalf("PEM 尾部不对: %q", lastLine(pem)) - } - // 每行(除首尾)不得超过 64 字符 —— openssl 对超长行会拒 - for _, ln := range strings.Split(strings.TrimSpace(pem), "\n") { - if strings.HasPrefix(ln, "-----") { - continue - } - if len(ln) > 64 { - t.Fatalf("PEM base64 行长度 %d > 64,openssl 会拒绝", len(ln)) - } - } - - // ② 最关键:真签名必须成功(DER/长度/换行任一错都在这里暴露) - if ok, _ := inner["signed"].(bool); !ok { - t.Fatalf("PEM 重建后 DPoP 签名失败: %v", inner["sign_error"]) - } - - // ③ 签名产物必须是三段式 JWS(header.payload.signature) - // 探针不直接回 JWS,但 signed=true 已表示 codeartsDpopSign 走完了 - // 整个 openssl 签名 + DER→raw 转换链路(任一环节失败都会返回 nil)。 -} - -func firstLine(s string) string { - if i := strings.IndexByte(s, '\n'); i >= 0 { - return s[:i] - } - return s -} - -func lastLine(s string) string { - s = strings.TrimRight(s, "\n") - if i := strings.LastIndexByte(s, '\n'); i >= 0 { - return s[i+1:] - } - return s -} - -// TestCodeartsKeyFromJwkRejectsBadJwk 钉住"坏 JWK 要响亮报错"。 -// -// 为什么需要:JWK 字段长度不对(例如被截断、或根本不是 P-256)时, -// 若静默构造出一个畸形 PEM,openssl 只会说「签名失败」—— 运维无从判断 -// 是凭据坏了还是代码坏了。必须在这里给可诊断的消息。 -func TestCodeartsKeyFromJwkRejectsBadJwk(t *testing.T) { - cases := []struct { - name string - jwk map[string]any - }{ - {"d 太短", map[string]any{ - "kty": "EC", "crv": "P-256", - "d": "AAAA", "x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4", - "y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM"}}, - {"缺 x", map[string]any{ - "kty": "EC", "crv": "P-256", - "d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U"}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - _, ps := poolCore(t) - out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": c.jwk}) - if err != nil { - return // 调用层报错也算响亮 - } - wrapped, _ := out.(map[string]any) - inner, _ := wrapped["json"].(map[string]any) - if inner == nil { - return - } - if e, _ := inner["error"].(string); e == "" { - if ok, _ := inner["signed"].(bool); ok { - t.Fatalf("坏 JWK 竟然签出了名 —— 应当报错") - } - } - }) - } -} - -// 让 goimports/json 相关导入不被误判未使用(此文件仅用 json 做类型断言辅助)。 -var _ = json.Marshal -var _ = os.Getenv - -// TestB64urlDecodeHandlesLongInput 钉住 base64url 解码的**长输入**正确性。 -// -// 这个判据补的是一个真实的测漏:我第一版的 b64urlDecode 在输出字节后 -// **没有清除已用掉的高位**(缺 `buf = buf % (2^nbits)`)。用 43 字符的 -// JWK 字段(32 字节)测时**结果完全正确** —— 于是那条 PEM 判据在变异后 -// 依然全绿,什么也没验到。 -// -// 只有长输入才暴露:累积缓冲不清高位时,double 精度在约 11 个 6bit 组后 -// 耗尽,math.floor 给出越界值,string.char 直接抛 invalid value。 -// 实测:把 `string.rep(43字符JWK, 10)` 喂进去,无掩码版本当场崩。 -// -// 所以这里必须用**长串**(远超过 11 组),断言解出的字节数与内容都正确。 -func TestB64urlDecodeHandlesLongInput(t *testing.T) { - // 96 个字符 → 72 字节。远超溢出临界点(11 组 = 66 bit)。 - const in = "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7UBpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U" - // 期望值由 Go 标准库算出(不用手写的 Lua 结果做锚,否则自证) - want := base64.RawURLEncoding.EncodeToString - - _, ps := poolCore(t) - out, err := ps.Call("channels", "__b64_probe", map[string]any{"s": in}) - if err != nil { - t.Fatalf("探针调用失败(很可能是 string.char 越界 = 缺高位掩码): %v", err) - } - wrapped, _ := out.(map[string]any) - inner, _ := wrapped["json"].(map[string]any) - if inner == nil { - t.Fatalf("探针返回缺 json 包装: %#v", out) - } - got, _ := inner["hex"].(string) - n, _ := inner["len"].(float64) - - // 期望长度由 Go 自己算(不要手推 len/4*3:输入不一定是 4 的倍数) - dec, derr := base64.RawURLEncoding.DecodeString(in) - if derr != nil { - t.Fatal(derr) - } - if int(n) != len(dec) { - t.Fatalf("解码长度 = %v,期望 %d", n, len(dec)) - } - // 与 Go 解码结果逐字节比对 - wantHex := "" - for _, b := range dec { - wantHex += string("0123456789abcdef"[b>>4]) + string("0123456789abcdef"[b&0xf]) - } - if got != wantHex { - t.Fatalf("解码内容不符(长输入下高位未清除):\n got=%s\nwant=%s", got, wantHex) - } - _ = want -} diff --git a/internal/core/channels_codearts_port_test.go b/internal/core/channels_codearts_port_test.go deleted file mode 100644 index ffcba95..0000000 --- a/internal/core/channels_codearts_port_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package core - -import ( - "fmt" - "testing" - - "modelrouter/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 deleted file mode 100644 index 2da0ef9..0000000 --- a/internal/core/channels_dead_model_test.go +++ /dev/null @@ -1,217 +0,0 @@ -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, "后面是句末标点"}, - {"model is not registered", true, "CodeArts/InferHub 下架模型的真实文案"}, - {"InferHub.002002009.404: The model is not registered, please request other model", true, - "CodeArts 完整 404 文案"}, - // ── 阴性:看着像但**不是**下架 ── - {"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, "空串"}, - // ── opencode zen 的**下架**文案(2026-10-20 生产实测 410)── - // 上游原话:Model space-bunny-free has been deprecated. Use - // minimax-m3.1-flash-free instead. 此前不识别 ⇒ 该模型一直留在源里, - // 用户选中必 410("渠道不可用"的观感来源之一)。 - {"Model space-bunny-free has been deprecated. Use minimax-m3.1-flash-free instead.", - true, "opencode zen 410 下架文案"}, - {"model has been deprecated", true, "同族最短形态(无模型名)"}, - // 阴性:参数/字段被弃用 ≠ 模型下架(主语不是 Model) - {"parameter temperature has been deprecated", false, "参数弃用不是模型下架"}, - {"the field max_tokens has been deprecated", 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_device_test.go b/internal/core/channels_device_test.go deleted file mode 100644 index 6cb682a..0000000 --- a/internal/core/channels_device_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package core - -import ( - "testing" -) - -// TestChannelsDevicePollBehavior 是 device-poll 流的行为对齐测试。 -// -// 决策表逐行搬自参考实现 pollMinimaxDeviceToken(minimax-oauth.ts), -// 上游用假服务器模拟,逐次响应由测试控制。断言的是**行为**(会话状态推进), -// 不是实现细节。决策表本体在 deviceHarness.runDecisionTable(与变异测试共用)。 -func TestChannelsDevicePollBehavior(t *testing.T) { - h := newDeviceHarness(t, mustChannelsSrc(t)) - // begin 直查:user_code 透传、授权 URL、verifier 不回显 - j := h.begin() - sess := j["session"].(map[string]interface{}) - if sess["user_code"] != "ABCD-EFGH" { - t.Fatalf("begin 应透传 user_code, 实得 %v", sess["user_code"]) - } - if j["url"] != "https://example/activate" { - t.Fatalf("begin 应给授权 URL, 实得 %v", j["url"]) - } - if _, has := j["device_verifier"]; has { - t.Fatal("verifier 绝不能回显") - } - // 决策表全过 - if !h.runDecisionTable() { - t.Fatal("device-poll 决策表未全过(见 runDecisionTable 内的行号)") - } -} - -// mustChannelsSrc 读 bundled channels 源码。 -func mustChannelsSrc(t *testing.T) string { - t.Helper() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - return src -} diff --git a/internal/core/channels_endpoint_test.go b/internal/core/channels_endpoint_test.go deleted file mode 100644 index 25beb2b..0000000 --- a/internal/core/channels_endpoint_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// 渠道端点判据 —— 钉住"base_url 不吞 endpoint 路径"这条铁律。 -// -// 为什么需要这个测试 -// ------------------ -// 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL)。把路径**同时** -// 写进 base_url 和 endpoint,拼出来就是重复路径。生产实测两次撞上同一族缺陷: -// -// zcode base_url=.../api/v1/zcode-plan/anthropic -// endpoint=/api/v1/zcode-plan/anthropic/v1/messages -// ⇒ .../anthropic/api/v1/zcode-plan/anthropic/v1/messages → 404 -// -// minimax base_url=https://agent.minimax.cn + adapter=openai(默认 endpoint -// /chat/completions)⇒ 打到 Web 前端首页,404「简单指令,无限可能」 -// -// 两者报错都是 404,但根因不同(路径重复 / 缺路径段)。单条断言咬不住两种, -// 所以下面分三条断言。 - -// platformDef 从**真实加载的插件**里读一个平台定义。 -// -// ★ 必须走真实加载而不是正则解析源码:PLATFORMS 是 Lua 表,源里有注释、 -// 条件拼装、以及文件末尾的绑定循环(refresh_fn / claim_fn / headers_fn 都在 -// 那里挂)。拿正则在源文本上判断"有没有 refresh 段"会被注释里的字样骗 —— -// 这个坑我上一轮差点踩进去。探针返回的是插件运行时看到的真相。 -func platformDef(t *testing.T, id string) map[string]any { - t.Helper() - _, ps := poolCore(t) - out, err := ps.Call("channels", "__platform_def_probe", map[string]any{"id": id}) - if err != nil { - t.Fatalf("__platform_def_probe(%s) 失败: %v", id, err) - } - // Plugins.Call 返回 {json:{...}, status:...} 包装(历史约定)。 - wrapped, ok := out.(map[string]any) - if !ok { - t.Fatalf("探针返回不是表: %T", out) - } - if e, ok := wrapped["error"].(string); ok && e != "" { - t.Fatalf("%s: %s", id, e) - } - inner, ok := wrapped["json"].(map[string]any) - if !ok { - t.Fatalf("探针返回缺 json 包装: %#v", wrapped) - } - return inner -} - -// TestChannelBaseURLDoesNotSwallowEndpoint 是最核心的一条: -// base_url 里**不得**出现 endpoint 也有的路径段。 -// -// 判别性:zcode 缺陷时 base_url 尾段是 "anthropic",endpoint 里也含 -// "/anthropic/" ⇒ 命中。修好后 base_url 只到 host,永不命中。 -func TestChannelBaseURLDoesNotSwallowEndpoint(t *testing.T) { - for _, id := range []string{"zcode", "minimax", "codearts", "trae", "lobsterai", "buddy"} { - t.Run(id, func(t *testing.T) { - p := platformDef(t, id) - base, _ := p["base_url"].(string) - ep, _ := p["endpoint"].(string) - base = strings.TrimRight(base, "/") - if base == "" { - t.Fatalf("%s 没有 base_url", id) - } - - // 从 base_url 里取出路径部分(host 之后),逐段看是否也出现在 endpoint 里。 - pathPart := "" - if i := strings.Index(base, "://"); i >= 0 { - if j := strings.Index(base[i+3:], "/"); j >= 0 { - pathPart = base[i+3+j:] - } - } - if pathPart == "" { - return // 只到 host,天然不会重复 - } - for _, seg := range strings.Split(strings.Trim(pathPart, "/"), "/") { - if seg == "" { - continue - } - if strings.Contains(ep, "/"+seg+"/") || strings.HasSuffix(ep, "/"+seg) { - t.Fatalf("%s: base_url 已含路径段 %q(base_url=%s),"+ - "endpoint 里又出现(endpoint=%s)⇒ 内核拼 base_url+endpoint 时路径重复", - id, seg, base, ep) - } - } - }) - } -} - -// TestMinimaxUsesAnthropicProtocolAndRealInferPath 钉住 minimax 的两层缺陷。 -// -// 参考 channel-pack/src/minimax-product.ts: -// -// MINIMAX_INFER_PATH = '/mavis/api/v1/llm/v1/messages' ← Anthropic Messages -// apiHost = 'https://agent.minimax.cn' -// -// 缺陷 A:base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions -// -// ⇒ 那是 Web 前端,404 首页。实测(2026-10-19): -// /mavis/api/v1/llm/v1/messages → 401 invalid access token(路径对) -// 裸 /chat/completions → 404 page not found -// /mavis/api/v1/chat/completions → 404 -// -// 缺陷 B:即使路径改对,openai 适配器的**请求体形状**也不对 -// -// (minimax 推理端点是 Anthropic 协议)。 -func TestMinimaxUsesAnthropicProtocolAndRealInferPath(t *testing.T) { - p := platformDef(t, "minimax") - ep, _ := p["endpoint"].(string) - adapter, _ := p["adapter"].(string) - - if !strings.HasSuffix(ep, "/mavis/api/v1/llm/v1/messages") { - t.Errorf("minimax endpoint = %q,应为 /mavis/api/v1/llm/v1/messages"+ - "(照参考 MINIMAX_INFER_PATH);否则请求会打到 Web 前端", ep) - } - if adapter != "anthropic" { - t.Errorf("minimax adapter = %q,应为 anthropic —— 推理端点是 Anthropic "+ - "Messages 协议,用 openai 适配器会把请求体拼错", adapter) - } -} - -// TestCodeartsDeclaresRefreshHook 钉住"声明了可续期就必须声明怎么续"。 -// -// 真实缺陷(2026-10-19 用户报「华为云源用不了」): -// codearts 的 security_token 只活 1 小时;account_refresh 走 refreshCredential(), -// 它要求 plat.refresh.url 或 plat.refresh_fn 存在。当时只声明了 refreshable=true -// (那是 accountView 里硬编码的期望),**没声明怎么续** ⇒ 恒出 -// 「该平台未声明续期方式」,自动续期链空转,日志反复 `模型刷新失败 HTTP 400`。 -// -// 为什么不能只看 refreshable:那是"这个账号的凭据类型可续",与"平台实现了 -// 续期"是两件事。前者为真而后者缺失,正是这个 bug 的形状。 -func TestCodeartsDeclaresRefreshHook(t *testing.T) { - p := platformDef(t, "codearts") - hasHook, _ := p["has_refresh_fn"].(bool) - hasDecl, _ := p["has_refresh_decl"].(bool) - if !hasHook && !hasDecl { - t.Fatal("codearts 既无 refresh_fn 也无 refresh.url —— " + - "account_refresh 会恒返回「该平台未声明续期方式」," + - "而 STS token 只有 1 小时寿命,过期后源的每个请求都会 400") - } - // 华为云 STS 要 DPoP 头,声明式 style 拿不到私钥 ⇒ 必须是钩子 - if !hasHook { - t.Error("codearts 只有声明式 refresh.url,但华为云 STS 需要 DPoP 头" + - "(jwk 在凭据里),声明式 style 发不出来 ⇒ 必须用 refresh_fn") - } -} diff --git a/internal/core/channels_endpoint_url_test.go b/internal/core/channels_endpoint_url_test.go deleted file mode 100644 index dbae9d2..0000000 --- a/internal/core/channels_endpoint_url_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package core - -import ( - "regexp" - "strings" - "testing" -) - -// ★ 源的 chat URL = `base_url + endpoint`(内核 provider.go ChatURL)。 -// -// 真实缺陷(生产实测):多个渠道平台的 `base_url` 里塞了**完整路径**, -// 而适配器默认 endpoint 又是 "/chat/completions" ⇒ 实际请求变成 -// -// https://copilot.tencent.com/v2/chat/completions/chat/completions -// -// ⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,不是模型错误)。 -// -// 受影响平台 8 个:buddy / cline / lobsterai / opencode / qoder / loomy / -// raccoon(zcode 当时已用 endpoint 覆盖,形态正确)。 -// -// ★ 为什么这个缺陷能活很久:**模型名被多个源服务**。 -// -// `deepseek-v4.1-flash` 同时由 4 个源提供(alittokenplan/vela/oin/channels-buddy…) -// ⇒ 网关在 buddy 源失败后会**继续尝试下一个源**并成功 ⇒ 用户看到"能回话", -// 于是"buddy 通道是好的"这个结论**完全没有证据**。 -// 而 `hy3` 只由 buddy 一个源提供 ⇒ 一调就暴露真因。 -// ⇒ 判据必须断言**源的 URL 形态**(结构不变量),不能靠"某个模型能调通"。 -// -// 判据:base_url 不得自带任何 chat 路径段(它只应是 host[+协议公共前缀]), -// 路径必须在 `endpoint` 里。 -func TestChannelSourceBaseURLHasNoChatPath(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - - // 只在平台定义区里找 base_url(避免误判注释/文档里的示例) - declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`) - // 已知适配器会把这些路径各追加一次;出现即说明 base_url 侵占了 endpoint 的地盘 - banned := []string{"/chat/completions", "/v1/messages", "/generateContent", "/api/chat"} - - found := 0 - for _, m := range declRe.FindAllStringSubmatch(s, -1) { - url := m[1] - found++ - for _, b := range banned { - if strings.Contains(url, b) { - t.Errorf("base_url 侵占了 endpoint 的路径:%s\n"+ - " 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),"+ - "适配器默认 endpoint=\"/chat/completions\" ⇒ 实际会请求 %s%s\n"+ - " 修法:base_url 只留 host,把 `%s` 移到该平台的 `endpoint = \"...\"`", - url, url, b, b) - } - } - } - if found == 0 { - t.Fatal("没扫到任何 base_url 声明(判据失效)") - } - t.Logf("✓ %d 个 base_url 均不含 chat 路径段", found) -} - -// ★ 反过来也要咬:说明该缺陷**确实已被修好**、而不是判据太松。 -// 用一段**故意的坏数据**验证判据的判别力(变异测试的静态等价物)。 -func TestChannelBaseURLJudgeBitesOnBadInput(t *testing.T) { - bad := ` base_url = "https://copilot.tencent.com/v2/chat/completions",` - declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`) - m := declRe.FindStringSubmatch(bad) - if m == nil { - t.Fatal("判据的正则匹配不到坏数据 ⇒ 判据无判别力(形同虚设)") - } - if !strings.Contains(m[1], "/chat/completions") { - t.Fatal("坏数据未被识别 ⇒ 判据无判别力") - } - t.Log("✓ 判据对坏数据有判别力") -} diff --git a/internal/core/channels_free_flag_test.go b/internal/core/channels_free_flag_test.go deleted file mode 100644 index 5cf6e21..0000000 --- a/internal/core/channels_free_flag_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package core - -import ( - "testing" -) - -// `isFree` 的判定链(照参考 cline-models.ts)。 -// -// ★ 修的是一个与注释自相矛盾的缺陷:cline 的 3 个模型来自远端 **free 数组**, -// 却声明在 `models` 里 ⇒ 被标成收费。用户看不到哪个可以不花钱用。 -// -// 参考的判定链(权威在前): -// -// ① 远端 free 数组(`recommended-models` 是**唯一权威**的免费集合; -// `/api/v1/models` 的 460 个 id 里根本没有 cline-free/*) -// ② 命名约定前缀 `cline-free/*`(远端未响应时的兜底) -// ③ 声明的 models_free -// ④ 能力表的 cost(models.dev) -// -// ⚠️ 免费是 **OR** 语义:多个来源任一确认免费就够。写成"后者覆盖前者"会让 -// 「远端说免费、能力表拉不到」退回收费 —— 正是这类静默降级。 -func TestIsFreeUsesAuthorityChain(t *testing.T) { - _, ps := poolCore(t) - - // cline 的免费模型必须被标成免费(远端/free_prefix/声明 任一命中即可) - out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "cline"}) - if err != nil { - t.Fatalf("model_list: %v", err) - } - j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) - got := map[string]bool{} - for _, it := range asArrInterface(j["models"]) { - if mm, ok := it.(map[string]interface{}); ok { - if id, ok := mm["id"].(string); ok { - if f, ok := mm["isFree"].(bool); ok { - got[id] = f - } - } - } - } - // `cline-free/*` 前缀命中 ⇒ 必须免费(命名约定兜底) - if !got["cline-free/mimo-v2.6-flash"] { - t.Errorf("cline-free/* 前缀的模型必须免费(命名约定兜底),实得 %v", got) - } - // stealth/space-bunny-alpha 来自远端 free 数组,也必须免费 - if !got["stealth/space-bunny-alpha"] { - t.Errorf("远端 free 数组里的模型必须免费,实得 %v", got) - } - - // 命名约定必须生效(远端未响应时唯一的兜底依据) - probe, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{ - "platform": "cline", "model_id": "cline-free/some-new-model", - }) - pj, _ := probe.(map[string]interface{})["json"].(map[string]interface{}) - if pj["is_free"] != true { - t.Error("远端未响应时,`cline-free/*` 前缀必须能判出免费(这是兜底依据)") - } - // 反向:收费通道的同名模型**不得**被判免费 - //(参考点名:免费模型是独立 id,`cline-free/x` 与 `x` 是两个不同模型) - probe2, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{ - "platform": "cline", "model_id": "deepseek/deepseek-v4.1-flash", - }) - pj2, _ := probe2.(map[string]interface{})["json"].(map[string]interface{}) - if pj2["is_free"] == true { - t.Error("收费通道的模型不得因为前缀规则被判成免费") - } -} diff --git a/internal/core/channels_gate_test.go b/internal/core/channels_gate_test.go deleted file mode 100644 index 43bc52d..0000000 --- a/internal/core/channels_gate_test.go +++ /dev/null @@ -1,65 +0,0 @@ -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) - // 一个通道 = 一个源:源名不含账号 id,按**本用例实际用的平台**找。 - sm, ok := srcHasAccount(srcs, "buddy") - if !ok { - t.Fatalf("账号未出通道源,实际源: %v", srcs) - } - _ = aid - // ① 串行 - 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_legacy_source_cleanup_test.go b/internal/core/channels_legacy_source_cleanup_test.go deleted file mode 100644 index c5cb3c9..0000000 --- a/internal/core/channels_legacy_source_cleanup_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package core - -import ( - "testing" -) - -// ★ 升级后**旧版按账号拆的源必须被清掉**,否则同一个渠道的模型出现两遍。 -// -// 真实事故(2026-10-19 用户报「同一个模型两个名字 / 源列表一堆不可读的东西」): -// 生产 config.yaml 里 6 对源并存 —— -// -// channels-trae-b8dd6b3df30e + channels-trae -// channels-buddy-buddy-main + channels-buddy -// …(trae/lobsterai/minimax/buddy/codearts/zcode 各一对) -// -// 新设计是「一通道一源」(`channels-`),换号靠同名 add_source 覆盖 -// api_key。旧版是「一账号一源」(`channels--<账号id>`)。升级时必须把 -// 旧形态清掉,否则: -// -// ① 模型列表里同一模型出现多遍,只能靠 12 位 hex 区分(人读不出来) -// ② 旧源指向已不该用的账号,却可能因为注册更早而**先**被调度到 -// -// 根因是形态假设错了:内核 `list_sources` 返回**裸数组**(见 kernel_actions.go -// 的 actionListSources,直接 `return out`),而迁移代码按 `{sources:[...]}` -// 信封去取 ⇒ 恒为 nil ⇒ 循环一次都不跑 ⇒ 静默什么都没清。 -// -// ★ 本判据的关键是**用裸数组形态喂数据**(也就是内核真实返回的形状)。 -// -// 如果按信封造夹具,即使代码是错的两边也"对上了",测不出任何东西 —— -// 这正是这个 bug 逃过全部既有判据的原因。 -func TestChannelsLegacyPerAccountSourceIsCleaned(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatalf("加载插件: %v", err) - } - if err := ps.SetPluginKernelPermission("channels", - []string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil { - t.Fatalf("授权: %v", err) - } - - // 造存量:一个旧形态源(带账号 hex)+ 一个别的通道的旧源(不该被误删)。 - mk := func(name string) { - if _, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{ - "name": name, - "base_url": "https://example.invalid", - "adapter": "openai", - "api_key": "sk-x", - "models": []interface{}{"m1"}, - }); err != nil { - t.Fatalf("造源 %s: %v", name, err) - } - } - mk("channels-trae-b8dd6b3df30e") // 旧形态:trae 的某个账号 - mk("channels-trae-deadbeef0123") // 旧形态:trae 的另一个账号 - mk("channels-buddy-buddy-main") // 别的通道 —— 不该被 trae 的同步碰到 - mk("unrelated-source") // 与 channels 无关,绝不能被删 - - // 放一个**真实**的 trae 账号(走 account_add,不是探针):没有账号就无从 - // 写源,也就走不到「写前先清旧源」那一步。account_add 本身也会同步一次源, - // 但那正是要测的路径。 - if _, err := ps.Call("channels", "account_add", map[string]interface{}{ - "platform": "trae", "account_id": "b8dd6b3df30e", - "credential": `{"access_token":"T","refresh_token":"R","nickname":"n"}`, - }); err != nil { - t.Fatalf("造账号: %v", err) - } - - // ★ 只走 `syncAccountSource` → `syncProviderSource` 这条路(迁移清理就在这里)。 - // 刻意**不用** reap_orphan_sources:它自己另有一套"形态 1 旧源"清理逻辑, - // 会把这里要测的迁移代码完全遮住 —— 用它做判据时,即使迁移代码是坏的 - // (信封假设的 bug 仍在)判据也会绿,等于没测。 - if _, err := ps.Call("channels", "__resync_sources_probe", map[string]interface{}{ - "platform": "trae", - }); err != nil { - t.Fatalf("resync: %v", err) - } - - names := map[string]bool{} - for _, s := range c.Sources() { - names[s.Name] = true - } - - for _, legacy := range []string{"channels-trae-b8dd6b3df30e", "channels-trae-deadbeef0123"} { - if names[legacy] { - t.Errorf("旧形态源 %s 没被清掉 —— 同一渠道的模型会在列表里出现两遍,"+ - "只能靠 12 位 hex 区分,用户读不出哪个是哪个", legacy) - } - } - if !names["channels-trae"] { - t.Error("新形态源 channels-trae 没建起来") - } - if names["unrelated-source"] != true { - t.Error("清理误伤了无关源 unrelated-source") - } - // ★ 这里才是真的越界判据:trae 的同步**只**该清 `channels-trae-` 前缀的旧源。 - // 如果前缀匹配写成贪婪/过宽(例如 `channels-(.+)-`),buddy 的源会被误删。 - if !names["channels-buddy-buddy-main"] { - t.Error("清理越界:trae 的同步删掉了 buddy 的源(前缀匹配过宽)") - } -} diff --git a/internal/core/channels_lobster_claim_test.go b/internal/core/channels_lobster_claim_test.go deleted file mode 100644 index 5384cb5..0000000 --- a/internal/core/channels_lobster_claim_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package core - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "modelrouter/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 deleted file mode 100644 index f7d65f7..0000000 --- a/internal/core/channels_lobster_exchange_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package core - -import ( - "encoding/json" - "modelrouter/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 deleted file mode 100644 index 073ab03..0000000 --- a/internal/core/channels_lobster_loopback_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package core - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "modelrouter/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_log_panel_test.go b/internal/core/channels_log_panel_test.go deleted file mode 100644 index b058875..0000000 --- a/internal/core/channels_log_panel_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package core - -import ( - "regexp" - "strings" - "testing" -) - -// ★ 插件日志面板:级别 + 归属 + 同文折叠(否则用户动作被 96% 的周期噪音淹没)。 -// -// 真实问题(生产实测):50 条日志里 **48 条(96%)** 是三个定时任务 -// (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now) -// 的重复回音,唯一模式只有 8 种 ⇒ 约 2 小时就把 50 条环形缓冲冲满一轮, -// **真正的用户动作**(如「账号删除」)被挤到看不见。 -// -// 三个结构性修法,每个都在源码里可断言: -// -// ① logEvent 支持 level(task/warn/error),前端默认折叠 task -// ② logEvent 支持 platform,前端按选中渠道过滤(修"点 buddy 看到 trae"的归属错误) -// ③ 同文折叠:紧邻同文不新增条目,只 n=n+1 -func TestChannelsLogEventCarriesLevelAndPlatform(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - - // ① 签名必须收 level 与 platform(三参) - if !regexp.MustCompile(`local function logEvent\(msg, level, platform\)`).MatchString(s) { - t.Error("logEvent 必须接收 (msg, level, platform) —— 否则级别与渠道归属都无从表达") - } - // ② 写入的条目必须带上这两个字段 - for _, field := range []string{"level = level or", "platform = platform"} { - if !strings.Contains(s, field) { - t.Errorf("日志条目必须写入 %s", field) - } - } - // ③ 同文折叠:紧邻同文时只加计数 - if !strings.Contains(s, "top.n = (top.n or 1) + 1") { - t.Error("必须实现紧邻同文折叠(否则周期任务的重复回音会把 50 条缓冲冲满)") - } - // ④ 折叠必须只针对**紧邻**条目 —— 否则一条每小时出现的消息会不断跳到最前, - // 时间线失去意义。断言比较对象是 log[1](栈顶)而不是任意条目。 - if !strings.Contains(s, "local top = log[1]") { - t.Error("折叠必须只比较**紧邻**的上一条(log[1]);对任意条目折叠会让时间线错乱") - } - t.Log("✓ logEvent 具备 level / platform / 紧邻同文折叠") -} - -// ★ 周期任务的**常规**结果必须是 task 级(默认折叠),异常必须是 warn/error。 -// -// 判据咬的是"这些具体调用点被降级了",而不是"存在 task 这个词" —— -// 后者只要有一处就为真,测不出"某个噪音源忘了降级"。 -func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - - // 这些是「每次跑都成功」的常规回音 —— 必须 task 级 - routine := []string{ - `logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")`, - `logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")`, - `logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")`, - } - for _, r := range routine { - if !strings.Contains(s, r) { - t.Errorf("周期任务的常规结果必须降级为 task(否则会淹没用户动作):\n %s", r) - } - } - // 这些是异常 —— 必须 warn(不能被一起折叠掉) - for _, pat := range []string{ - `logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")`, - `logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")`, - `logEvent("凭据续期失败 " .. #failed .. " 个:`, - } { - if !strings.Contains(s, pat) { - t.Errorf("异常必须保持 warn(不能被当常规噪音折叠):%s", pat) - } - } - // 「已同步源」是周期重同步的常规回音 —— 降级为 task 且带平台归属。 - // 文案在"一个通道=一个源"重构后多带了账号 id(便于排查"用的是哪个号"), - // 故这里只钉住"task 级 + 带 provider"这两个**语义**要件,不钉住整句字面量 —— - // 钉字面量会让每次改文案都要改测试,而这条测试要防的是"级别升回 info"。 - if !strings.Contains(s, `logEvent("已同步源 " .. name`) { - t.Error("「已同步源」这条回音丢了(改名了?)") - } - // 匹配"已同步源 ... "task", provider)"——跨行(文案带账号 id 后跨了两行), - // 故用 (?s) 让 . 能匹配换行。 - if !regexp.MustCompile(`(?s)logEvent\("已同步源 " \.\. name.*?"task", provider\)`).MatchString(s) { - t.Error("「已同步源」是周期重同步的常规回音,必须 task 级且带平台归属(provider)") - } - t.Log("✓ 周期噪音已降级,异常保持 warn") -} - -// ★ 前端的三个过滤维度必须都在(级别门槛 / 例行折叠 / 按渠道过滤)。 -func TestChannelsLogPanelHasFilters(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - - // ① 日志卡片必须**独立于渠道详情**(C 的核心:修正归属) - // 判据:日志卡片的 DOM 不是 ch-detail 的子元素。 - // 做法:取 ch-detail 开始到 ch-log-card 之间的片段,要求中间出现过 - // ch-detail 的闭合(即两者是兄弟而非父子)。 - iDetail := strings.Index(s, `id="ch-detail"`) - iLogCard := strings.Index(s, `id="ch-log-card"`) - if iDetail < 0 || iLogCard < 0 { - t.Fatal("找不到 ch-detail 或 ch-log-card(判据需更新)") - } - if iLogCard < iDetail { - t.Fatal("运行日志卡片应在渠道详情**之后**(作为兄弟节点)") - } - // 统计 ch-detail 打开到日志卡片之间未闭合的 div 层数 - seg := s[iDetail:iLogCard] - depth := 0 - for _, m := range regexp.MustCompile(` 0 { - t.Errorf("运行日志卡片仍嵌在渠道详情内部(depth=%d)⇒ 点开某渠道会看到别的渠道的日志(归属错误)", depth) - } - - // ② 三个过滤维度 - for _, id := range []string{`id="ch-log-verbose"`, `id="ch-log-level"`} { - if !strings.Contains(s, id) { - t.Errorf("缺少日志视图控件 %s", id) - } - } - if !strings.Contains(s, "function chLogRender()") { - t.Error("必须实现 chLogRender()(否则过滤逻辑无处落地)") - } - // ③ 按渠道过滤 + 切渠道重渲染 - if !strings.Contains(s, "if (selPlat) return e.platform === selPlat;") { - t.Error("必须按当前选中渠道过滤日志(否则归属错误以另一种形式回来)") - } - if !strings.Contains(s, "chLogRender();\n };\n") { - t.Error("chSelect 切换渠道后必须重渲染日志") - } - t.Log("✓ 日志面板独立成卡片,三个过滤维度齐备") -} - -// ★ 日志截断必须按**字符**切,不能按字节 —— 否则中文被切出无效 UTF-8。 -// -// 真实缺陷(生产实测,v2.0.33 截图里可见):日志尾显示成 -// -// `能力增强不�` -// -// 根因:`tostring(cerr):sub(1, 120)` —— Lua 的 sub 按**字节**切,而中文占 3 字节 -// ⇒ 切在字符中间产生替换字符(U+FFFD)。一句本来完整的诊断看起来像数据损坏。 -func TestLogTruncationIsUTF8Safe(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - - // ① 必须存在按字符截断的助手 - if !strings.Contains(s, "plugin.utf8Clip = function(s, maxBytes)") { - t.Fatal("必须实现 plugin.utf8Clip(按字符截断)") - } - // ② 中文文案上的截断不得再用裸 sub() - for _, bad := range []string{ - `tostring(cres):sub(1, 120)`, - `tostring(cerr):sub(1, 120)`, - `table.concat(failed, "; "):sub(1, 300)`, - } { - if strings.Contains(s, bad) { - t.Errorf("中文文案的截断不能用裸 sub()(按字节切会产生 U+FFFD):%s", bad) - } - } - // ③ 三处必须已换成 utf8Clip - for _, want := range []string{ - "plugin.utf8Clip(cres, 120)", - "plugin.utf8Clip(cerr, 120)", - `plugin.utf8Clip(table.concat(failed, "; "), 300)`, - } { - if !strings.Contains(s, want) { - t.Errorf("缺少 UTF-8 安全截断调用:%s", want) - } - } - // ④ 助手必须**只在超长时**才截断(否则正常文案会被莫名缩短) - if !strings.Contains(s, "if #s <= maxBytes then return s end") { - t.Error("utf8Clip 必须对未超长的输入原样返回") - } - // ⑤ 回退逻辑必须处理续字节(10xxxxxx)与孤立首字节 - if !strings.Contains(s, "elseif b >= 0xC0 then") { - t.Error("utf8Clip 必须回退孤立的 UTF-8 首字节(否则末尾仍是半个字符)") - } - t.Log("✓ 截断已 UTF-8 安全") -} diff --git a/internal/core/channels_loomy_test.go b/internal/core/channels_loomy_test.go deleted file mode 100644 index 4158a89..0000000 --- a/internal/core/channels_loomy_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package core - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "modelrouter/internal/lua" -) - -// loomyHarness:Loomy 微信扫码长轮询的测试台。 -// -// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"): -// -// 408 = 等待扫码;404 = 已扫码待确认(继续轮询); -// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效 -type loomyHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - polls int32 - confirmAt int32 - gotAuth string -} - -func newLoomyHarness(t *testing.T) *loomyHarness { - t.Helper() - h := &loomyHarness{t: t, confirmAt: 3} - h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"): - w.Header().Set("Content-Type", "text/html") - // 夹具必须用**真实微信页面的形态**:uuid 内嵌在 - // 。 - // 用别的形态(如 var uuid = "...")测出来的正则不是生产要用的那条。 - fmt.Fprint(w, ``) - case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"): - n := atomic.AddInt32(&h.polls, 1) - w.Header().Set("Content-Type", "text/plain") - if n == 1 { - fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码 - return - } - if n < atomic.LoadInt32(&h.confirmAt) { - fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认 - return - } - fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认 - case r.URL.Path == "/login/thirdAccount/bind/auth": - h.gotAuth = r.Header.Get("Authorization") - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`) - default: - w.WriteHeader(404) - } - })) - t.Cleanup(h.ts.Close) - - c := kernelTestCore(t) - h.ps = c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := h.ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) - if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "loomy", - "wechat": map[string]interface{}{ - "auth_url": h.ts.URL + "/connect/qrconnect?state={state}", - "qr_url": h.ts.URL + "/connect/qrcode/{uuid}", - "poll_url": h.ts.URL + "/connect/l/qrconnect", - "account_base": h.ts.URL, - }, - }); err != nil { - t.Fatalf("set_endpoints: %v", err) - } - return h -} - -func (h *loomyHarness) begin() (map[string]interface{}, string) { - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin", - }) - if err != nil { - h.t.Fatal(err) - } - j := out.JSON.(map[string]interface{}) - return j, j["session"].(map[string]interface{})["id"].(string) -} - -func (h *loomyHarness) pollOnce(sid string) map[string]interface{} { - out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", - }) - if err != nil { - h.t.Fatal(err) - } - return out.JSON.(map[string]interface{}) -} - -// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。 -func TestLoomyWechatPollBehavior(t *testing.T) { - h := newLoomyHarness(t) - - t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) { - atomic.StoreInt32(&h.polls, 0) - j, _ := h.begin() - qr, _ := j["qr_url"].(string) - if !strings.Contains(qr, "UUID-TEST-1") { - t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr) - } - }) - - t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) { - atomic.StoreInt32(&h.polls, 0) - atomic.StoreInt32(&h.confirmAt, 99) - _, sid := h.begin() - // 第一拍 408 - if st := h.pollOnce(sid); st["state"] != "pending" { - t.Fatalf("408 应 pending, 实得 %v", st["state"]) - } - // 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里) - st := h.pollOnce(sid) - if st["state"] != "pending" { - t.Fatalf("404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"]) - } - }) - - t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) { - atomic.StoreInt32(&h.polls, 0) - atomic.StoreInt32(&h.confirmAt, 3) - _, sid := h.begin() - h.pollOnce(sid) // 408 - h.pollOnce(sid) // 404 - st := h.pollOnce(sid) // 405 - if st["state"] != "completed" { - t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"]) - } - // 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer - if !strings.HasPrefix(h.gotAuth, "account ") { - t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth) - } - if !strings.Contains(h.gotAuth, ":") { - t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth) - } - v, ok, _ := h.ps.GetSecret("channels", "channels:loomy") - if !ok || !strings.Contains(v, "RCODE-1") { - t.Fatalf("凭据应含 rcode, 实得 %q", v) - } - }) - - t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) { - for _, tc := range []struct{ errcode, want string }{ - {"403", "取消"}, {"402", "失效"}, - } { - hh := newLoomyHarness(t) - hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") { - fmt.Fprint(w, ``) - return - } - fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode) - }) - _, sid := hh.begin() - st := hh.pollOnce(sid) - if st["state"] != "failed" { - t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"]) - } - } - }) -} diff --git a/internal/core/channels_lua_locals_test.go b/internal/core/channels_lua_locals_test.go deleted file mode 100644 index 1e1e0ff..0000000 --- a/internal/core/channels_lua_locals_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package core - -import ( - "os/exec" - "regexp" - "strings" - "testing" -) - -// ★ channels.lua 必须留出顶层 local 余量 —— 否则**下一次顶层改动就编译失败**。 -// -// 实测事实(2026-10-16):该文件的主 chunk 曾**顶到 Lua 上限 200**, -// 往顶部加**哪怕 1 个**顶层 local 就报 -// -// main function has more than 200 local variables -// -// 而这炸的不是某个功能 —— 是**整个渠道插件**(插件加载失败 ⇒ 全渠道不可用)。 -// -// ⚠️ 判据为什么用 luajit 而不是 luac: -// -// 内核链接的是 libluajit-5.1.so.2;而 `luac`(Lua 5.4)对同一文件给出 -// **不同的**结论。用错工具会得到假绿。 -// -// ⚠️ 判据为什么是「注入 N 个 local 仍能编译」而不是「数一遍 local」: -// -// 主 chunk 的 local 数受**作用域嵌套**影响,静态计数不可靠(同一个文件 -// 用不同脚本数出 228/232/248 三个数)。而"能不能再加"是**决定性**的, -// 也正是我们真正需要的性质。 -func TestChannelsLuaHasLocalHeadroom(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if _, err := exec.LookPath("luajit"); err != nil { - t.Skip("luajit 不在 PATH(判据需要与内核相同的 Lua 实现)") - } - - const headroom = 10 - for _, n := range []int{1, headroom} { - probe := strings.Repeat("local __headroom = 1\n", n) + src - cmd := exec.Command("luajit", "-e", "local f,e=loadfile(arg[1]); if not f then io.stderr:write(tostring(e)) os.exit(1) end", "/dev/stdin") - cmd.Stdin = strings.NewReader(probe) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("★ 顶层 local 余量不足:加 %d 个 local 就无法编译 ⇒ "+ - "下一次顶层改动会**整插件加载失败**(全渠道不可用)\n %s\n"+ - "修法:把纯常量收进 plugin.K 表(挂表字段不占 local 槽位)", n, strings.TrimSpace(string(out))) - } - } - t.Logf("✓ channels.lua 仍有 ≥%d 个顶层 local 余量", headroom) -} - -// 配对判据:常量必须确实在 plugin.K 里(防止有人把余量"省"出来的同时又 -// 把常量改回裸 local —— 那会让下一个改动的余量再次归零)。 -func TestChannelsConstantsLiveInPluginK(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - if !strings.Contains(s, "plugin.K = {") { - t.Fatal("必须有 plugin.K 常量表(它是 local 余量的来源)") - } - // 抽样:这些曾占用 local 槽位的纯常量必须已挂表 - for _, n := range []string{"STORE_NAME", "SESSION_TTL", "B64_ALPHABET", "MODELS_DEV_URL"} { - if !regexp.MustCompile(`(?m)^\s+` + n + ` = `).MatchString(s) { - t.Errorf("%s 应定义在 plugin.K 表内", n) - } - if regexp.MustCompile(`(?m)^local ` + n + ` = `).MatchString(s) { - t.Errorf("%s 又变回裸 local 了(会吃掉 local 余量)", n) - } - } - t.Log("✓ 纯常量已集中在 plugin.K") -} diff --git a/internal/core/channels_models_remote_test.go b/internal/core/channels_models_remote_test.go deleted file mode 100644 index 3f8b71e..0000000 --- a/internal/core/channels_models_remote_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package core - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "modelrouter/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_more_test.go b/internal/core/channels_more_test.go deleted file mode 100644 index 7ea5d58..0000000 --- a/internal/core/channels_more_test.go +++ /dev/null @@ -1,405 +0,0 @@ -package core - -import ( - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "modelrouter/internal/lua" -) - -// channelsMoreTest:新引入的三条真实流程 + 两个边界流的行为对齐测试。 -// 平台定义、决策表、端点逐字照抄参考(见 channels.lua 注释),测试照参考行为搬。 - -// ---- Cline(WorkOS 设备码 + register 两步) ---- - -// clineHarness 假上游:device code / authenticate / register 三端点。 -type clineHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - tok atomic.Value // 当前 authenticate 响应脚本 []scriptStep - step int32 -} - -func newClineHarness(t *testing.T) *clineHarness { - t.Helper() - h := &clineHarness{t: t} - h.tok.Store([]scriptStep{}) - h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/user_management/authorize/device": - fmt.Fprint(w, `{"device_code":"CD1","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) - case "/user_management/authenticate": - i := int(atomic.AddInt32(&h.step, 1)) - 1 - s := h.tok.Load().([]scriptStep) - if i >= len(s) { - w.WriteHeader(500) - fmt.Fprint(w, `{"error":"unexpected"}`) - return - } - if s[i].http != 0 && s[i].http != 200 { - w.WriteHeader(s[i].http) - } - fmt.Fprint(w, s[i].body) - case "/api/v1/auth/register": - // 校验请求体是驼峰 accessToken/refreshToken(参考 registerClineTokens) - b, _ := io.ReadAll(r.Body) - for _, want := range []string{`"accessToken":"WO-AT-1"`, `"refreshToken":"WO-RT-1"`} { - if !strings.Contains(string(b), want) { - t.Errorf("register 请求体缺 %s: %s", want, b) - } - } - fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-AT-1","refreshToken":"CLINE-RT-1","userInfo":{"clineUserId":"u1","email":"dev@example.com"}}}`) - default: - w.WriteHeader(404) - } - })) - t.Cleanup(h.ts.Close) - - c := kernelTestCore(t) - h.ps = c.Plugins() - src, _ := readBundledChannels(t) - if err := h.ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "cline", - "device": map[string]interface{}{"code_url": h.ts.URL + "/user_management/authorize/device", "token_url": h.ts.URL + "/user_management/authenticate"}, - "register": map[string]interface{}{"url": h.ts.URL + "/api/v1/auth/register"}, - }); err != nil { - t.Fatalf("set_endpoints: %v", err) - } - return h -} - -func (h *clineHarness) begin() map[string]interface{} { - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"cline"}`, - }) - if err != nil { - h.t.Fatal(err) - } - return out.JSON.(map[string]interface{}) -} -func (h *clineHarness) poll(id string) map[string]interface{} { - out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": id}, - }) - return out.JSON.(map[string]interface{}) -} - -// runWorkosTable 是 WorkOS(Cline)分支的行为契约,供变异测试杀 workos 变异体。 -// 决策表 5 行,跟 TestClineWorkOSBehavior 同源(这里只返回布尔,不报细节)。 -func (h *clineHarness) runWorkosTable() bool { - st := func(steps ...string) map[string]interface{} { - h.tok.Store(scriptSteps(steps...)) - atomic.StoreInt32(&h.step, 0) - j := h.begin() - sid := j["session"].(map[string]interface{})["id"].(string) - return h.poll(sid) - } - _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) - // 行1: 非200+error=authorization_pending → 等 - if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" { - return false - } - // 行2: 非200+error=slow_down → 等 + interval+1(基线 1) - h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`)) - atomic.StoreInt32(&h.step, 0) - j := h.begin() - sid := j["session"].(map[string]interface{})["id"].(string) - if j2 := h.poll(sid); j2["state"] != "pending" { - return false - } else if iv, _ := j2["interval"].(float64); iv != 2 { - return false - } - // 行3: error=access_denied → failed - if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" { - return false - } - // 行4: 成功(access+refresh)→ register 换 CLINE token → completed - if j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`); j["state"] != "completed" || j["account"] != "dev@example.com" { - return false - } - v, _, _ := h.ps.GetSecret("channels", "channels:cline") - if v != "CLINE-AT-1" { - return false - } - // 行5: 非200无error字段 → failed(不是死等) - if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" { - return false - } - return true -} - -// TestClineWorkOSBehavior:WorkOS 判据(error 字段)+ register 两步 + slow_down +1s。 -func TestClineWorkOSBehavior(t *testing.T) { - h := newClineHarness(t) - _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) - - st := func(steps ...string) map[string]interface{} { - h.tok.Store(scriptSteps(steps...)) - atomic.StoreInt32(&h.step, 0) - j := h.begin() - sid := j["session"].(map[string]interface{})["id"].(string) - return h.poll(sid) - } - - t.Run("行1 error=authorization_pending_非200也继续等", func(t *testing.T) { - if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" { - t.Fatalf("应 pending: %v", j) - } - }) - t.Run("行2 error=slow_down_退避1秒", func(t *testing.T) { - h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`)) - atomic.StoreInt32(&h.step, 0) - j := h.begin() - sid := j["session"].(map[string]interface{})["id"].(string) - j2 := h.poll(sid) - if j2["state"] != "pending" { - t.Fatalf("应 pending: %v", j2) - } - if iv, _ := j2["interval"].(float64); iv != 2 { // 基线 1 + slow_down_step 1 - t.Fatalf("WorkOS slow_down 应 interval+1s 得 2, 实得 %v", iv) - } - }) - t.Run("行3 error=access_denied_失败带拒绝", func(t *testing.T) { - if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" { - t.Fatalf("应 failed: %v", j) - } - }) - t.Run("行4 成功_access+refresh_再打register_凭据是CLINE-AT-1", func(t *testing.T) { - j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`) - if j["state"] != "completed" || j["account"] != "dev@example.com" { - t.Fatalf("应 completed+dev@example.com: %v", j) - } - v, _, _ := h.ps.GetSecret("channels", "channels:cline") - if v != "CLINE-AT-1" { - t.Fatalf("凭据应是 register 的 CLINE-AT-1, 实得 %q", v) - } - }) - t.Run("行5 非200无error字段_失败", func(t *testing.T) { - if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" { - t.Fatalf("应 failed: %v", j) - } - }) -} - -// ---- Buddy(CodeBuddy state 轮询) ---- - -type buddyHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - tokStep int32 - tokOk int32 - acctOk int32 -} - -func newBuddyHarness(t *testing.T) *buddyHarness { - t.Helper() - h := &buddyHarness{t: t, tokOk: 1, acctOk: 1} - h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch { - case r.Method == "POST" && r.URL.Path == "/v2/plugin/auth/state": - fmt.Fprint(w, `{"data":{"state":"ST-1","authUrl":"https://www.codebuddy.cn/login/?platform=ide&state=ST-1"}}`) - case r.Method == "GET" && r.URL.Path == "/v2/plugin/auth/token": - i := atomic.AddInt32(&h.tokStep, 1) - if atomic.LoadInt32(&h.tokOk) == 0 { - w.WriteHeader(200) - fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`) - return - } - if i == 1 { - fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`) - } else { - fmt.Fprint(w, `{"data":{"accessToken":"BD-AT-1","refreshToken":"BD-RT-1"}}`) - } - case r.Method == "GET" && r.URL.Path == "/v2/plugin/login/account": - if atomic.LoadInt32(&h.acctOk) == 0 { - w.WriteHeader(500) - fmt.Fprint(w, `{"code":1}`) - return - } - fmt.Fprint(w, `{"data":{"nickName":"buddy-dev"}}`) - default: - w.WriteHeader(404) - } - })) - t.Cleanup(h.ts.Close) - - c := kernelTestCore(t) - h.ps = c.Plugins() - src, _ := readBundledChannels(t) - if err := h.ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "buddy", - "state": map[string]interface{}{"state_url": h.ts.URL + "/v2/plugin/auth/state", "token_url": h.ts.URL + "/v2/plugin/auth/token", "account_url": h.ts.URL + "/v2/plugin/login/account"}, - }); err != nil { - t.Fatalf("set_endpoints: %v", err) - } - return h -} - -func (h *buddyHarness) begin() (map[string]interface{}, string) { - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"buddy"}`, - }) - if err != nil { - h.t.Fatal(err) - } - j := out.JSON.(map[string]interface{}) - return j, j["session"].(map[string]interface{})["id"].(string) -} - -// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。 -// 这正是用户报障("登录了但无法获取账号信息")的那条路径。 -func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) { - add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"buddy"}`, - }) - if err != nil { - h.t.Fatal(err) - } - aid := add.JSON.(map[string]interface{})["account_id"].(string) - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid), - }) - if err != nil { - h.t.Fatal(err) - } - j := out.JSON.(map[string]interface{}) - return j, j["session"].(map[string]interface{})["id"].(string), aid -} - -func (h *buddyHarness) accountList() []interface{} { - out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"buddy"}`, - }) - if err != nil { - h.t.Fatal(err) - } - m := out.JSON.(map[string]interface{}) - accts, _ := m["accounts"].([]interface{}) - return accts -} -func (h *buddyHarness) poll(id string) map[string]interface{} { - out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": id}, - }) - return out.JSON.(map[string]interface{}) -} - -// TestBuddyStatePollBehavior:state-poll 两拍(先 11217 后 token)+ 账号查询 + account 失败不阻塞。 -func TestBuddyStatePollBehavior(t *testing.T) { - h := newBuddyHarness(t) - _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) - - t.Run("行1 state申请给出authUrl", func(t *testing.T) { - j, _ := h.begin() - if j["url"] == "" { - t.Fatalf("应有 authUrl: %v", j) - } - }) - t.Run("行2 首拍11217仍pending_次拍token_completed", func(t *testing.T) { - atomic.StoreInt32(&h.tokStep, 0) - _, sid := h.begin() - if j := h.poll(sid); j["state"] != "pending" { - t.Fatalf("首拍应 pending: %v", j) - } - if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" { - t.Fatalf("次拍应 completed+buddy-dev: %v", j) - } - // 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token: - // 必须带 refresh_token,否则 refreshable=false、无法静默续期。 - v, _, _ := h.ps.GetSecret("channels", "channels:buddy") - if !strings.Contains(v, `"access_token":"BD-AT-1"`) { - t.Fatalf("凭据应含 access_token, 实得 %q", v) - } - if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) { - t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v) - } - if !strings.Contains(v, "buddy-dev") { - t.Fatalf("凭据应回填昵称, 实得 %q", v) - } - }) - t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) { - atomic.StoreInt32(&h.tokStep, 0) - atomic.StoreInt32(&h.acctOk, 0) - defer atomic.StoreInt32(&h.acctOk, 1) - _, sid := h.begin() - h.poll(sid) - j := h.poll(sid) - if j["state"] != "completed" { - t.Fatalf("account 失败不应阻塞: %v", j) - } - }) - - // 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。 - // 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`, - // 而池账号 credentialRef 是 `channels:buddy:` → account_list 报 - // has_credential=false、credits 报「无凭据」——即用户报障的 - // 「登录了但无法获取账号信息」。 - t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) { - atomic.StoreInt32(&h.tokStep, 0) - _, sid, aid := h.beginPool() - h.poll(sid) // 首拍 11217 - j := h.poll(sid) - if j["state"] != "completed" { - t.Fatalf("池路径登录应完成: %v", j) - } - // ① 凭据必须落在池账号的 ref 上 - v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid) - if !ok || v == "" { - t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok) - } - if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) { - t.Fatalf("池凭据应是完整 JSON: %q", v) - } - // ② account_list 必须报 has_credential=true(UI 读的就是这个字段) - found := false - for _, raw := range h.accountList() { - a, _ := raw.(map[string]interface{}) - if a["id"] == aid { - found = true - if a["has_credential"] != true { - t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"]) - } - if a["refreshable"] != true { - t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"]) - } - } - } - if !found { - t.Fatalf("池账号 %s 未出现在 account_list", aid) - } - }) -} - -// ---- 通用 ---- - -func scriptSteps(steps ...string) []scriptStep { - var out []scriptStep - for _, b := range steps { - code := 200 - switch { - case strings.HasPrefix(b, "400 "): - code, b = 400, b[4:] - case strings.HasPrefix(b, "403 "): - code, b = 403, b[4:] - case strings.HasPrefix(b, "500 "): - code, b = 500, b[4:] - } - out = append(out, scriptStep{http: code, body: b}) - } - return out -} diff --git a/internal/core/channels_orphan_callback_test.go b/internal/core/channels_orphan_callback_test.go deleted file mode 100644 index 4ce57c5..0000000 --- a/internal/core/channels_orphan_callback_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package core - -import ( - "os" - "path/filepath" - "testing" - - "modelrouter/internal/lua" -) - -// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。 -// -// 真实事故:生产 `/etc/modelrouter/plugins/` 攒了 **18 个 callback-*.port** -// (对应的 .json 早已消失)。它们永远不会被清,因为: -// -// · loopbacks 追踪表只活在内存(plugin.state.loopbacks); -// · 进程被强杀时 on_unload 没机会跑; -// · 重启后内存表是空的 ⇒ reapLoopback 根本不知道它们存在。 -// -// 判据看的是**目录的真实状态**(孤儿必须消失、活跃会话的文件不能被误删), -// 而不是"某函数被调用过"。 -func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) { - dir := t.TempDir() - pluginDir := filepath.Join(dir, "plugins") - if err := os.MkdirAll(pluginDir, 0755); err != nil { - t.Fatal(err) - } - t.Setenv("MODELROUTER_PLUGIN_DIR", pluginDir) - - // 摆上一轮的残留(模拟上一进程被强杀后的现场) - orphans := []string{ - filepath.Join(pluginDir, "callback-deadbeef0001.port"), - filepath.Join(pluginDir, "callback-deadbeef0001.json"), - filepath.Join(pluginDir, "callback-deadbeef0002.port"), - } - for _, f := range orphans { - if err := os.WriteFile(f, []byte("12345"), 0600); err != nil { - t.Fatal(err) - } - } - // 一份**不属于**回调的文件:必须不被误删(否则清扫过头会毁掉账号池) - keep := filepath.Join(pluginDir, ".channels-store.json") - if err := os.WriteFile(keep, []byte(`{"accounts":{}}`), 0600); err != nil { - t.Fatal(err) - } - - // 加载插件 = 走真实启动路径 - c := kernelTestCore(t) - defer c.Close() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - ps := c.Plugins() - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - // 顶层清扫代码在加载期就跑了;给 popen 的 rm 一点时间落盘 - for i := 0; i < 50; i++ { - left := 0 - for _, f := range orphans { - if _, err := os.Stat(f); err == nil { - left++ - } - } - if left == 0 { - break - } - } - for _, f := range orphans { - if _, err := os.Stat(f); err == nil { - t.Fatalf("孤儿回调文件未被清扫: %s —— 它会永久留在磁盘上"+ - "(真实事故:生产攒了 18 个)", filepath.Base(f)) - } - } - if _, err := os.Stat(keep); err != nil { - t.Fatalf("清扫过头了:%s 被误删(账号池/索引绝不能碰)", filepath.Base(keep)) - } - t.Log("✓ 孤儿回调文件被清,非回调文件保留") -} - -var _ = lua.Plugins{} diff --git a/internal/core/channels_orphan_source_test.go b/internal/core/channels_orphan_source_test.go deleted file mode 100644 index 7e30bd0..0000000 --- a/internal/core/channels_orphan_source_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package core - -import ( - "testing" -) - -// ★ 孤儿源必须能被识别、且**只删该删的**。 -// -// 真实事故(2026-10-17 生产实测):`channels-buddy-585b335b64cf` 的账号早已删除, -// 源却仍在 config.yaml 里对外服务 —— 独占服务 hy4-preview-x(404), -// 对 6 个共享模型则排在正常源**前面**先被尝试(每次白付 ~3.4s 上游往返)。 -// -// 这是 v2.0.28(remove_source 漏声明)的**存量残骸**:修声明只防新增,不清理存量。 -func TestChannelsReapOrphanSources(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatalf("加载插件: %v", err) - } - // 生产里 channels 被授予这些(见 plugin.kernel 声明);夹具必须同样授权, - // 否则 add_source 会被拒 ⇒ 造不出孤儿源。 - if err := ps.SetPluginKernelPermission("channels", - []string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil { - t.Fatalf("授权: %v", err) - } - - // 装载前先清空账号池:这样插件初始化流程自己注册的源也一并算孤儿。 - cleanup := func() interface{} { - r, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{ - "dry_run": false, - }) - if err != nil { - t.Fatalf("reap_orphan_sources: %v", err) - } - return r - } - cleanup() - - // 造出**真实形态**:走 account_add 登记账号(它会注册源), - // 再用 hidden 探针把账号从池里摘掉 —— 模拟 v2.0.28 事故的残留形态 - // (账号已删、撤源被拒 ⇒ 源留下继续对外服务)。 - // 不走 account_delete:那条路径的收尾已被 TestAccountDelete* 覆盖, - // 而我们要的恰恰是"源留下了"这个状态。 - // - // ★ 一通道一源之后:`account_add` 产生的源名是 `channels-buddy`(不含账号 id), - // 所以它**造不出**本用例要的「旧版按账号拆源」形态。这里直接用 - // __add_source_for_test 注册遗留形态的源名 —— 这才是要验的对象本身 - // (reap 的职责就是清掉升级前遗留的这类源)。 - if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{ - "name": "channels-buddy-deadbeef1234", "base_url": "http://127.0.0.1:1", - "adapter": "openai", "models": []interface{}{"m1"}, - }); err != nil { - t.Fatalf("注册遗留源(1): %v", err) - } else if j := jsonOf(t, r); j["error"] != nil { - t.Fatalf("注册遗留源(1) 报错: %v", j["error"]) - } - // ★ 第二个孤儿:账号 id **含 `-`**。这个用例是判别性的 —— - // 用 `name:match("^channels%-(.+)-(.+)$")` 解析会被 Lua 的**贪婪** `-` - // 切成 plat="buddy-dead"/acct="acct"(未知平台)⇒ 源被跳过 ⇒ **孤儿漏杀**。 - // (实测:只有这个用例能把"按已知平台前缀匹配"与"贪婪匹配"区分开; - // 不含 `-` 的 id 两种实现结果相同,咬不住。) - if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{ - "name": "channels-buddy-dead-acct-1", "base_url": "http://127.0.0.1:1", - "adapter": "openai", "models": []interface{}{"m1"}, - }); err != nil { - t.Fatalf("注册遗留源(2): %v", err) - } else if j := jsonOf(t, r); j["error"] != nil { - t.Fatalf("注册遗留源(2) 报错: %v", j["error"]) - } - // 运维手工源:名字与账号源形态无关 ⇒ 绝不碰 - if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{ - "name": "my-handmade-source", "base_url": "http://127.0.0.1:1", - "adapter": "openai", "models": []interface{}{"m1"}, - }); err != nil { - t.Fatalf("注册手工源: %v", err) - } else if j := jsonOf(t, r); j["error"] != nil { - // ★ 必须看 json 里的 error:内核把失败放在返回体里, - // Go 侧 err 仍是 nil ⇒ 只查 err 会让"源根本没注册"静默通过。 - t.Fatalf("注册手工源报错: %v", j["error"]) - } - // 遗留源对应的账号本来就不存在(这正是"孤儿"的定义),无需再摘。 - - // ① dry_run(默认)只报告、不删 - res, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{}) - if err != nil { - t.Fatal(err) - } - j := jsonOf(t, res) - orphans, _ := j["orphans"].([]interface{}) - seen := map[string]bool{} - for _, o := range orphans { - if s, _ := o.(string); s != "" { - seen[s] = true - } - } - for _, want := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} { - if !seen[want] { - t.Fatalf("★ 没识别出孤儿源 %s(dry_run 应报告它)\n"+ - " 若 id 含 `-` 的那个漏了 ⇒ 说明用了贪婪 match 解析名字,应改为按已知平台前缀匹配\n"+ - " 实得 %v", want, seen) - } - } - if seen["my-handmade-source"] { - t.Fatalf("★ 误伤运维手工源(名字不匹配账号源形态,绝不能碰)") - } - if j["dry_run"] != true { - t.Fatalf("dry_run 必须默认开启(破坏性操作不得默认生效),实得 %v", j["dry_run"]) - } - if j["removed"].(float64) != 0 { - t.Fatalf("dry_run=true 时不得真删,实得 removed=%v", j["removed"]) - } - - // ② 确认 dry_run 真的没删(看源列表) - after, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{}) - if err != nil { - t.Fatal(err) - } - names := sourceNames(jsonOf(t, after)) - if !names["channels-buddy-deadbeef1234"] { - t.Fatalf("dry_run 竟真删了!源列表: %v", names) - } - // 手工源必须存活(名字不匹配账号源形态 ⇒ 不该被识别为孤儿) - if !names["my-handmade-source"] { - t.Fatalf("dry_run 时就丢了手工源?源列表: %v", names) - } - - // ③ 真删:孤儿消失、手工源存活 - res, err = ps.Call("channels", "reap_orphan_sources", map[string]interface{}{"dry_run": false}) - if err != nil { - t.Fatal(err) - } - j = jsonOf(t, res) - if j["ok"] != true { - t.Fatalf("清理应成功,实得 %v", j) - } - after, err = ps.Call("channels", "__list_sources_probe", map[string]interface{}{}) - if err != nil { - t.Fatal(err) - } - names = sourceNames(jsonOf(t, after)) - for _, dead := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} { - if names[dead] { - t.Fatalf("★ 孤儿源 %s 未被清掉(它会继续对外服务无凭据的请求): %v", dead, names) - } - } - if !names["my-handmade-source"] { - t.Fatalf("★ 误删了运维手工源: %v", names) - } - t.Logf("✓ 孤儿源已清、手工源存活") -} - -// sourceNames 把 __list_sources_probe 的返回摊平成 name→true。 -func sourceNames(j map[string]interface{}) map[string]bool { - out := map[string]bool{} - if arr, ok := j["sources"].([]interface{}); ok { - for _, x := range arr { - if m, ok := x.(map[string]interface{}); ok { - if s, ok := m["name"].(string); ok { - out[s] = true - } - } - } - } - return out -} diff --git a/internal/core/channels_platforms_logged_in_test.go b/internal/core/channels_platforms_logged_in_test.go deleted file mode 100644 index eafea60..0000000 --- a/internal/core/channels_platforms_logged_in_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package core - -import "testing" - -// `platforms` 的 logged_in 必须看**账号池**,不能只看旧单账号保险库键。 -// -// 真实缺陷(2026-10-20 用户报「codearts 还是不可用」): -// `list_platforms` 当时写的是 `credentialOf("channels:" .. id)` —— 那是旧版 -// 单账号的键;而现网凭据键是 `channels::`(一平台多账号)。 -// 于是**所有**通过账号池接入的平台都被判成未登录,UI 把渠道卡片渲染成"未接入", -// 用户看到的正是"渠道不可用",而底层模型其实完全能用。 -// -// 判据钉两层:池里的账号让 logged_in=true,且账号标识能被 UI 拿到。 -func TestPlatformsLoggedInFromAccountPool(t *testing.T) { - _, ps := poolCore(t) - if _, err := ps.Call("channels", "account_add", map[string]interface{}{ - "platform": "codearts", "account_id": "acct-pool-1", - "credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`, - "nickname": "pool-nick", - }); err != nil { - t.Fatalf("account_add: %v", err) - } - // list_platforms 是 HTTP 路由处理器(不在 plugin.exports 里),走探针取同一份数据 - m, err := ps.Call("channels", "__platforms_probe", map[string]interface{}{}) - if err != nil { - t.Fatalf("__platforms_probe: %v", err) - } - w, _ := m.(map[string]interface{}) - j, _ := w["json"].(map[string]interface{}) - arr, _ := j["platforms"].([]interface{}) - if len(arr) == 0 { - t.Fatalf("platforms 不应为空") - } - var got map[string]interface{} - for _, raw := range arr { - p, _ := raw.(map[string]interface{}) - if p["id"] == "codearts" { - got = p - } - } - if got == nil { - t.Fatalf("platforms 里找不到 codearts") - } - if got["logged_in"] != true { - t.Fatalf("账号池里有凭据的账号,logged_in 必须为 true;实得 %#v", got) - } - if got["account_id"] != "acct-pool-1" { - t.Errorf("account_id 应回池里的账号 id;实得 %#v", got["account_id"]) - } - if got["account"] != "pool-nick" { - t.Errorf("account 应回池里的昵称;实得 %#v", got["account"]) - } - if s, _ := got["accounts"].(float64); s < 1 { - t.Errorf("accounts 计数应 >=1;实得 %#v", got["accounts"]) - } -} diff --git a/internal/core/channels_plugin_test.go b/internal/core/channels_plugin_test.go deleted file mode 100644 index ad71574..0000000 --- a/internal/core/channels_plugin_test.go +++ /dev/null @@ -1,438 +0,0 @@ -package core - -import ( - "encoding/json" - "testing" - - "modelrouter/internal/lua" -) - -// TestChannelsPluginEndToEnd 是「同类插件」的完整验收: -// 内置的 channels 插件在**真实内核**上跑通「登录 → 存凭据 → 接入为上游源」。 -// -// 这条链路横跨本次所有新增能力,所以它是最有信息量的一个测试: -// -// __llmsproxy_kernel add_source 把平台注册为上游源 -// 内核权限 默认拒绝,需运维授权 -// 额外出站主机授权 登录域不在源列表里,默认被出站白名单拒绝(plugin.hosts) -// 登录会话 插件自持(plugin.state.sessions),内核无登录概念 -func TestChannelsPluginEndToEnd(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatalf("读取内置 channels 插件: %v", err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatalf("加载 channels: %v", err) - } - // 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码 - // (否则会出现在生产 UI 的平台列表里)。 - injectTestPlatforms(t, ps, examplePlatformFixture) - - // 1. 平台清单可用(未授权任何东西也应能列出) - out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{ - Method: "GET", Role: "admin", - }) - if err != nil { - t.Fatalf("list_platforms: %v", err) - } - body, _ := out.JSON.(map[string]interface{}) - plats, _ := body["platforms"].([]interface{}) - if len(plats) < 3 { - t.Fatalf("应至少 3 个平台, 实得 %d", len(plats)) - } - - // 2. 发起登录 → 拿到会话与 URL - out, err = ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"example"}`, Role: "admin", - }) - if err != nil { - t.Fatalf("start_login: %v", err) - } - m := out.JSON.(map[string]interface{}) - if m["error"] != nil { - t.Fatalf("发起登录失败: %v", m["error"]) - } - if m["url"] == nil || m["url"] == "" { - t.Fatal("应返回用户要打开的登录 URL") - } - sess := m["session"].(map[string]interface{}) - id, _ := sess["id"].(string) - if id == "" { - t.Fatal("应返回会话 id") - } - - // 3. 轮询到 pending - out, _ = ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": id}, Role: "admin", - }) - if st := out.JSON.(map[string]interface{})["state"]; st != "pending" { - t.Fatalf("应为 pending, 实得 %v", st) - } - - // 4. 未授权内核动作时,接入源必须失败且**如实报告**(不是假装成功) - out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin", - }) - if err != nil { - t.Fatalf("complete_login: %v", err) - } - done := out.JSON.(map[string]interface{}) - if done["source_error"] == nil { - t.Fatal("未授权 add_source 时应报告 source_error") - } - t.Logf("未授权时的如实报告: %v", done["source_error"]) - - // 凭据必须已在保险库(登录本身成功了) - tok, ok, _ := ps.GetSecret("channels", "channels:example") - if !ok || tok != "tok-1" { - t.Fatalf("凭据应已存保险库: ok=%v tok=%q", ok, tok) - } - // 且不得出现在 state 里 - if stateHasSecret(ps, "channels", "tok-1") { - t.Fatal("凭据泄漏进了 plugin.state") - } - - // 5. 运维授权后,重新接入应当真的把源注册进内核 - if err := ps.SetPluginKernelPermission("channels", - []string{"add_source", "list_sources", "status"}, false); err != nil { - t.Fatal(err) - } - out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin", - }) - if err != nil { - t.Fatalf("complete_login(授权后): %v", err) - } - done = out.JSON.(map[string]interface{}) - if done["source_error"] != nil { - t.Fatalf("授权后仍失败: %v", done["source_error"]) - } - if done["source"] != "channels-example" { - t.Fatalf("源名不对: %v", done["source"]) - } - - // 内核侧必须真的有这个源 - found := false - for _, s := range c.Sources() { - if s.Name == "channels-example" { - found = true - if s.BaseURL != "https://api.example.com/v1" { - t.Errorf("base_url 未落库: %q", s.BaseURL) - } - } - } - if !found { - t.Fatal("插件接入的源没有出现在内核源列表里") - } - // 平台声明的模型应可路由 - models := c.Registry().ModelList() - hasModel := false - for _, id := range models { - if id == "example-chat" { - hasModel = true - } - } - if !hasModel { - t.Errorf("平台模型未进入可路由列表: %v", models) - } - t.Logf("已接入源 channels-example,模型可见: %v", models) -} - -// TestChannelsPluginDeclaresItsNeeds 断言插件如实声明了它需要什么。 -// -// 声明是运维审批的依据:插件不声明,设置页就无法显示"申请中", -// 运维只能等某个功能静默失效后才发现。 -func TestChannelsPluginDeclaresItsNeeds(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - // 内核动作声明 - views := ps.KernelPermissions() - var declared []string - for _, v := range views { - if v.Plugin == "channels" { - declared = v.Declared - } - } - if len(declared) == 0 { - t.Fatal("channels 应声明它需要的内核动作") - } - for _, want := range []string{"add_source", "status"} { - found := false - for _, d := range declared { - if d == want { - found = true - } - } - if !found { - t.Errorf("应声明 %q, 实得 %v", want, declared) - } - } - // 额外出站主机声明(非源 host,需单独批准) - hosts := ps.PluginExtraHosts("channels") - if len(hosts) == 0 { - t.Fatal("channels 应声明额外出站主机") - } - t.Logf("声明内核动作=%v 额外主机=%v", declared, hosts) -} - -// TestChannelsPluginHandlersRequireStatus 是一个契约回归:插件的每个 handler -// 都必须返回 {status,json/body},否则内核会**静默丢弃**它(实测踩过: -// `return {url=…}` 得到 200 空响应,没有任何错误信息)。 -func TestChannelsPluginHandlersRequireStatus(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) - - // 每个 handler 至少要在正常输入下给出非空响应 - cases := []struct { - fn string - req *lua.HandlerRequest - what string - }{ - {"list_platforms", &lua.HandlerRequest{Method: "GET"}, "平台列表"}, - {"status_page", &lua.HandlerRequest{Method: "GET"}, "状态页"}, - {"poll_login", &lua.HandlerRequest{Method: "GET", Query: map[string]string{"id": "nope"}}, "轮询(未知会话)"}, - {"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "发起登录"}, - {"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"nope"}`}, "发起登录(未知平台)"}, - {"complete_login", &lua.HandlerRequest{Method: "POST", Body: `{}`}, "完成登录(缺字段)"}, - {"claim_daily", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "领额度(未登录)"}, - } - for _, tc := range cases { - out, err := ps.InvokeHandler("channels", tc.fn, tc.req) - if err != nil { - t.Errorf("%s (%s) 返回错误: %v", tc.fn, tc.what, err) - continue - } - if out.JSON == nil && out.Body == "" { - t.Errorf("%s (%s) 返回空响应 —— 内核会静默丢弃,调用方看不到任何原因", - tc.fn, tc.what) - } - } -} - -// jsonMarshalForTest 序列化一个接口值,用于检查凭据是否出现在 state 里。 -func jsonMarshalForTest(v interface{}) ([]byte, error) { return json.Marshal(v) } - -// stateHasSecret 检查凭据是否泄漏进 plugin.state。 -func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool { - st := ps.State(plugin) - if st == nil { - return false - } - b, err := jsonMarshalForTest(st) - if err != nil { - return false - } - return contains(string(b), secret) -} - -// examplePlatformFixture 是端到端测试用的占位平台(对应此前内联在生产源码里 -// 的 "example" 平台)。它经 inject_platforms 注入,且**只在测试进程内存在** —— -// 生产插件的 PLATFORMS 只含真实厂商。 -const examplePlatformFixture = `[ - { - "id": "example", - "name": "Example 平台(测试夹具)", - "flow": "link-poll", - "login_url": "https://auth.example.com/device", - "base_url": "https://api.example.com/v1", - "adapter": "openai", - "models": ["example-chat", "example-reason"], - "checkin": {"url": "https://api.example.com/v1/api/daily/claim", "method": "POST", "body": "{}"} - } -]` - -// injectTestPlatforms 把测试夹具注入插件(运行时,不落盘)。 -func injectTestPlatforms(t *testing.T, ps *lua.Plugins, fixtureJSON string) { - t.Helper() - out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{ - Method: "POST", Body: `{"platforms":` + fixtureJSON + `}`, Role: "admin", - }) - if err != nil { - t.Fatalf("inject_platforms: %v", err) - } - m, _ := out.JSON.(map[string]interface{}) - if m["ok"] != true { - t.Fatalf("注入测试平台失败: %v", m) - } -} - -// TestInjectPlatformsRejectsBadInput 锁定注入机制的三条边界: -// 1. 不得覆盖内建平台(注入不能劫持真实厂商) -// 2. 重复 id 拒绝 -// 3. 空数组 = 清空注入(夹具不跨测试残留) -// -// 这个机制存在的理由:夹具内联进生产源码会让它们出现在生产 UI 的平台列表里 -// (真实踩过 —— 16 个平台里有 4 个是夹具)。 -func TestInjectPlatformsRejectsBadInput(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - - call := func(body string) map[string]interface{} { - out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{ - Method: "POST", Body: body, Role: "admin", - }) - if err != nil { - t.Fatalf("inject_platforms(%s): %v", body, err) - } - return out.JSON.(map[string]interface{}) - } - platforms := func() []interface{} { - out, _ := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{Method: "GET", Role: "admin"}) - return out.JSON.(map[string]interface{})["platforms"].([]interface{}) - } - - base := len(platforms()) - if base == 0 { - t.Fatal("生产目录应有真实厂商平台") - } - - // 注入一个合法夹具 → 列表 +1 - if m := call(`{"platforms":[{"id":"fixture-a","name":"A","flow":"paste","base_url":"https://a.example/v1"}]}`); m["ok"] != true { - t.Fatalf("合法注入应成功: %v", m) - } - if got := len(platforms()); got != base+1 { - t.Fatalf("注入后平台数 = %d, 期望 %d", got, base+1) - } - - // 1. 覆盖内建平台 → 拒绝 - if m := call(`{"platforms":[{"id":"buddy","name":"劫持"}]}`); m["error"] == nil { - t.Fatal("覆盖内建平台必须被拒绝") - } - // 2. 重复 id → 拒绝 - if m := call(`{"platforms":[{"id":"fixture-b"},{"id":"fixture-b"}]}`); m["error"] == nil { - t.Fatal("重复 id 必须被拒绝") - } - // 无 id → 拒绝 - if m := call(`{"platforms":[{"name":"no-id"}]}`); m["error"] == nil { - t.Fatal("缺 id 必须被拒绝") - } - - // 3. 空数组 = 清空注入,回到基线 - if m := call(`{"platforms":[]}`); m["ok"] != true { - t.Fatalf("清空应成功: %v", m) - } - if got := len(platforms()); got != base { - t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base) - } -} - -// TestChannelsBrowserStackDeps 锁定浏览器栈的依赖预检能力。 -// -// 这个能力此前**不存在**:代码里写着「运行 deploy 脚本一键安装」,但安装机制 -// 根本没有(deploy.sh 里零浏览器栈内容),于是 VNC 兜底在缺依赖的机器上只会 -// 报一句含糊的"进程启动失败"。现在如实报告缺什么、装哪个包。 -func TestChannelsBrowserStackDeps(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - out, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{ - Method: "GET", Role: "admin", - }) - if err != nil { - t.Fatalf("deps_status: %v", err) - } - m, ok := out.JSON.(map[string]interface{}) - if !ok { - t.Fatalf("deps_status 应返回对象: %#v", out.JSON) - } - if _, has := m["ok"]; !has { - t.Fatal("deps_status 必须报告 ok") - } - if _, has := m["missing"]; !has { - t.Fatal("deps_status 必须报告 missing 清单") - } - if m["ok"] == false { - hint, _ := m["install_hint"].(string) - if hint == "" { - t.Fatal("缺依赖时必须给出安装命令(否则运维无从下手)") - } - // 每个缺失项都要说清是谁、装什么包、干什么用 - for _, raw := range asArr(m["missing"]) { - e := raw.(map[string]interface{}) - for _, k := range []string{"bin", "pkg", "why"} { - if s, _ := e[k].(string); s == "" { - t.Fatalf("缺少字段 %s: %v", k, e) - } - } - } - t.Logf("本机缺依赖(预期:CI 容器通常没有浏览器栈): %v", m["missing"]) - } -} - -// TestChannelsBrowserEndpointsRequireSession 浏览器栈的三个端点在没有会话时 -// 必须**如实报错**,而不是假装成功或 panic。 -func TestChannelsBrowserEndpointsRequireSession(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - - // 无会话时 collect 必须报错 - out, err := ps.InvokeHandler("channels", "collect_credentials", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin", - }) - if err != nil { - t.Fatalf("collect_credentials: %v", err) - } - if out.JSON.(map[string]interface{})["error"] == nil { - t.Fatal("无浏览器会话时 collect_credentials 必须报错") - } - - // status 如实说 running=false - out, err = ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{ - Method: "GET", Role: "admin", - }) - if err != nil { - t.Fatalf("browser_status: %v", err) - } - if out.JSON.(map[string]interface{})["running"] != false { - t.Fatal("无会话时 running 应为 false") - } - - // stop 幂等:无会话时也返回 ok - out, err = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{ - Method: "POST", Role: "admin", - }) - if err != nil { - t.Fatalf("stop_browser: %v", err) - } - if out.JSON.(map[string]interface{})["ok"] != true { - t.Fatal("stop_browser 应幂等成功") - } -} diff --git a/internal/core/channels_pool_reason_test.go b/internal/core/channels_pool_reason_test.go deleted file mode 100644 index 20f35ad..0000000 --- a/internal/core/channels_pool_reason_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package core - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" - - "modelrouter/internal/lua" -) - -// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。 -// -// 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让 -// **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天, -// 而号里其实有钱。两者的**正确动作完全相反**: -// -// exhausted → 等额度重置 -// unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定) -// -// 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。 -func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) { - t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) { - h := newPoolReasonHarness(t) - h.addAccount("a1") - h.serveBalance(0, 0) - got := h.reason() - if got["reason"] != "exhausted" { - t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v", - got["reason"], got["detail"]) - } - }) - - t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) { - h := newPoolReasonHarness(t) - h.addAccount("a1") - h.serveBalanceError(500) - got := h.reason() - if got["reason"] != "unknown" { - t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+ - "(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"]) - } - if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") { - t.Fatalf("说明必须点明是查询失败,实得 %q", d) - } - }) - - t.Run("没有启用的账号 → no_accounts", func(t *testing.T) { - h := newPoolReasonHarness(t) - got := h.reason() - if got["reason"] != "no_accounts" { - t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"]) - } - }) - - t.Run("有可用账号 → usable", func(t *testing.T) { - h := newPoolReasonHarness(t) - h.addAccount("a1") - h.serveBalance(100, 0) - got := h.reason() - if got["reason"] != "usable" { - t.Fatalf("有钱时应报 usable,实得 %v", got["reason"]) - } - if u, _ := got["usable"].(float64); int(u) != 1 { - t.Fatalf("usable 应为 1,实得 %v", got["usable"]) - } - }) - - t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) { - h := newPoolReasonHarness(t) - // ⚠ 必须有**启用的账号**:判定的优先级是 - // no_accounts → 无余额端点 → usable → unknown/exhausted - // 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高, - // 那是**正确**行为),把正确的实现判成了错。 - h.addAccountOn("minimax", "m1") - // minimax 未声明 balance 端点 - got := h.reasonOn("minimax") - if got["reason"] != "unknown" { - t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v", - got["reason"], got["detail"]) - } - if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") { - t.Fatalf("说明必须点明无法判定,实得 %q", d) - } - }) -} - -// 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted -// (用户动作不同:解锁 vs 等重置)。 -func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) { - h := newPoolReasonHarness(t) - h.addAccount("a1") - // total_fn 取包列表:只有永久积分(到期未知) - h.serveBalance(0, 50) - if _, err := h.ps.Call("channels", "__set_lock_permanent_probe", - map[string]interface{}{"lock": true}); err != nil { - t.Fatal(err) - } - got := h.reason() - if got["reason"] != "locked" { - t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v", - got["reason"], got["detail"]) - } - // 解锁后同一份余额应为 usable(证明 locked 不是"用尽") - if _, err := h.ps.Call("channels", "__set_lock_permanent_probe", - map[string]interface{}{"lock": false}); err != nil { - t.Fatal(err) - } - h.serveBalance(0, 50) - if got2 := h.reason(); got2["reason"] != "usable" { - t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"]) - } -} - -// ── harness ── - -type poolReasonHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - body atomic.Value - fail atomic.Bool - hasAcct atomic.Bool -} - -func newPoolReasonHarness(t *testing.T) *poolReasonHarness { - t.Helper() - h := &poolReasonHarness{t: t} - // buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts - h.body.Store(buddyPkgBody(0, 0)) - h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if h.fail.Load() { - w.WriteHeader(500) - return - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, h.body.Load().(string)) - })) - t.Cleanup(h.ts.Close) - - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - h.ps = ps - // buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶 - if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "buddy", - "balance": map[string]interface{}{"url": h.ts.URL}, - }); err != nil { - t.Logf("set_endpoints(balance) 未支持: %v", err) - } - return h -} - -func (h *poolReasonHarness) addAccount(id string) { - h.t.Helper() - h.addAccountOn("buddy", id) -} - -// addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致 -// "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的 -// no_accounts —— 判据写错而非实现错)。 -func (h *poolReasonHarness) addAccountOn(platform, id string) { - h.t.Helper() - if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{ - "platform": platform, "account_id": id, - "credential": `{"access_token":"AT","uid":"u1"}`, - }); err != nil { - h.t.Fatalf("account_add(%s): %v", platform, err) - } - h.hasAcct.Store(true) -} - -// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。 -// -// ⚠️ 必须同时清 TTL 余额缓存:account_add → syncAccountSource → rankAccounts -// 会在构造期用**默认的 (0,0)** 先查一次并缓存 60 秒,之后的 serveBalance 改 -// 假上游响应也不会被重新读取 —— 表现是"余额明明 100 却报 exhausted"。 -// __ttl_reset_probe 是插件自己的清缓存出口(与生产同一实现)。 -func (h *poolReasonHarness) serveBalance(expiring, permanent int) { - h.fail.Store(false) - h.body.Store(buddyPkgBody(expiring, permanent)) - if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil { - h.t.Fatalf("清 TTL 缓存: %v", err) - } -} - -// buddyPkgBody 拼一份 buddy 真实形态的余额响应: -// 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain / -// DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。 -func buddyPkgBody(expiring, permanent int) string { - pkgs := []string{} - if expiring > 0 { - // 3 天到期(< 15 天窗口) - endMs := (time.Now().Unix() + 3*86400) * 1000 - pkgs = append(pkgs, fmt.Sprintf( - `{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs)) - } - if permanent > 0 { - // DeductionEndTime=0(未知)⇒ 永久桶 - pkgs = append(pkgs, fmt.Sprintf( - `{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent)) - } - return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}` -} - -func (h *poolReasonHarness) serveBalanceError(code int) { - h.fail.Store(true) - // 同上:清掉构造期缓存,否则"查询失败"用例会命中旧的成功缓存。 - if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil { - h.t.Fatalf("清 TTL 缓存: %v", err) - } -} - -func (h *poolReasonHarness) reason() map[string]interface{} { - h.t.Helper() - return h.reasonOn("buddy") -} - -func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} { - h.t.Helper() - res, err := h.ps.Call("channels", "credits_pool_reason", - map[string]interface{}{"platform": platform}) - if err != nil { - h.t.Fatalf("credits_pool_reason: %v", err) - } - return jsonOf(h.t, res) -} diff --git a/internal/core/channels_pool_test.go b/internal/core/channels_pool_test.go deleted file mode 100644 index 7f6ff38..0000000 --- a/internal/core/channels_pool_test.go +++ /dev/null @@ -1,477 +0,0 @@ -package core - -import ( - "strings" - "testing" - - "modelrouter/internal/lua" - - "fmt" - "net/http" - "net/http/httptest" -) - -// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。 -// 账号池增删改、上游源随 enabled/模型开关同步、供应商整体开关、备份导出。 - -func poolCore(t *testing.T) (*Core, *lua.Plugins) { - t.Helper() - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "remove_source", "status", "probe_all", "reset_health"}, false) - return c, ps -} - -// listSourcesViaKernel 直查内核 list_sources(actionListSources 返回 []map)。 -func listSourcesViaKernel(t *testing.T, c *Core) []map[string]interface{} { - t.Helper() - out, err := c.Invoke(nil, "list_sources", map[string]interface{}{}) - if err != nil { - t.Fatalf("list_sources: %v", err) - } - if ms, ok := out.([]map[string]interface{}); ok { - return ms - } - raw, _ := out.([]interface{}) - var srcs []map[string]interface{} - for _, s := range raw { - if m, ok := s.(map[string]interface{}); ok { - srcs = append(srcs, m) - } - } - return srcs -} - -func invoke(t *testing.T, ps *lua.Plugins, fn, body string) map[string]interface{} { - t.Helper() - out, err := ps.InvokeHandler("channels", fn, &lua.HandlerRequest{Method: "POST", Body: body}) - if err != nil { - t.Fatalf("invoke %s: %v", fn, err) - } - return out.JSON.(map[string]interface{}) -} - -// asArr 规整桥接后的数组。Lua 空表过桥是 map[string]interface{}(无法与空数组 -// 区分,见 vm.go luaTableToGoValue);内核 list_sources 的 models 过桥是 []string。 -func asArr(v interface{}) []interface{} { - switch a := v.(type) { - case []interface{}: - return a - case []string: - out := make([]interface{}, len(a)) - for i, s := range a { - out[i] = s - } - return out - } - return nil -} - -func srcHasAccount(srcs []map[string]interface{}, needle string) (map[string]interface{}, bool) { - for _, sm := range srcs { - if name, _ := sm["name"].(string); strings.Contains(name, needle) { - return sm, true - } - } - return nil, false -} - -// TestChannelsAccountPoolBehavior:账号池 CRUD + 上游源同步 + 模型/供应商开关 + 备份。 -func TestChannelsAccountPoolBehavior(t *testing.T) { - c, ps := poolCore(t) - - t.Run("账号增删改", func(t *testing.T) { - m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"我的号"}`) - if m["ok"] != true { - t.Fatalf("account_add: %v", m) - } - aid, _ := m["account_id"].(string) - if aid == "" { - t.Fatalf("account_add 应返回 account_id: %v", m) - } - m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) - accts := asArr(m["accounts"]) - if len(accts) != 1 { - t.Fatalf("应有1个账号: %d", len(accts)) - } - a := accts[0].(map[string]interface{}) - if a["nickname"] != "我的号" || a["enabled"] != true { - t.Fatalf("账号字段: %v", a) - } - invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"nickname":"改名"}}`) - m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) - if asArr(m["accounts"])[0].(map[string]interface{})["nickname"] != "改名" { - t.Fatalf("改名失败") - } - invoke(t, ps, "account_delete", `{"platform":"minimax","account_id":"`+aid+`"}`) - m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) - if n := len(asArr(m["accounts"])); n != 0 { - t.Fatalf("删除后应为0, 实得 %d", n) - } - }) - - t.Run("模型开关同步上游源", func(t *testing.T) { - m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`) - aid, _ := m["account_id"].(string) - // ★ 一个通道 = 一个源:「加账号」同步出的是**通道级**源 `channels-`, - // 源名里**不含**账号 id(这正是本次重构的目的 —— 换号对客户端透明, - // 用户书签里的 `源:模型` 不会因为换号而失效)。 - // 故这里按平台名找源,不按 aid 找。 - sm, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax") - if !ok { - t.Fatalf("带凭据账号应已同步出通道源,实际源: %v", listSourcesViaKernel(t, c)) - } - // 而且源名必须是通道级那个(旧版会带 -<12hex> 后缀) - if nm, _ := sm["name"].(string); nm != "channels-minimax" { - t.Fatalf("通道源名应为 channels-minimax,实得 %q(账号 id=%s)", nm, aid) - } - // 不写死具体数量:兜底目录是照参考 *-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}`) - // 同样按平台名找(源名不含账号 id) - sm, _ = srcHasAccount(listSourcesViaKernel(t, c), "minimax") - if n := len(asArr(sm["models"])); n != total-1 { - t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n) - } - invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`) - m = invoke(t, ps, "provider_status", `{}`) - for _, s := range asArr(m["statuses"]) { - x := s.(map[string]interface{}) - if x["id"] == "minimax" && x["closed"] != true { - t.Fatalf("全关模型后应 closed=true: %v", x) - } - } - invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":false}`) - }) - - t.Run("enabled开关同步上游源", func(t *testing.T) { - m := invoke(t, ps, "account_list", `{"platform":"minimax"}`) - accts := asArr(m["accounts"]) - if len(accts) == 0 { - t.Fatal("上个用例应留有账号") - } - aid := accts[0].(map[string]interface{})["id"].(string) - // ★ 语义已变(一个通道 = 一个源),断言按「通道源是否存在」判,不按数量差。 - // - // 为什么不能断言 `after == before-1`:内核的 remove_source 有一条正当 - // 保护 —— **拒绝删掉最后一个源**(空 registry 会让每个请求变 503)。 - // 本夹具只有这一个源,撤源请求会被这条保护挡住,数量差恒为 0, - // 于是断言无论实现对错都失败。生产上有 20+ 源不会撞这条保护, - // 但判据不能依赖"恰好有别的源"这种环境事实。 - if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok { - t.Fatal("前置:minimax 通道源应存在") - } - invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`) - // 停用唯一账号 ⇒ 该通道无可用号 ⇒ 插件**尝试**撤源。夹具里会撞上内核 - // 「拒绝删最后一个源」的保护,故不断言源数量,改断言插件自己的判据: - // provider_status 里该通道的 enabled 应转为 false(无可用账号)。 - // ⚠️ provider_status 的字段形状:`enabled` 是**嵌套在 accounts 里的计数** - // (accounts = {total=N, enabled=M}),不是顶层布尔。顶层只有 `closed` - // (表示"该平台所有模型都被关掉")。写断言前必须看清形状 —— - // 我第一版按顶层 `enabled == false` 判,永远不成立。 - st := invoke(t, ps, "provider_status", `{}`) - noUsable := false - for _, x := range asArr(st["statuses"]) { - row := x.(map[string]interface{}) - if row["id"] != "minimax" { - continue - } - if acc, ok := row["accounts"].(map[string]interface{}); ok { - if n, _ := acc["enabled"].(float64); n == 0 { - noUsable = true - } - } - } - if !noUsable { - t.Fatalf("停用唯一账号后,minimax 的 accounts.enabled 应为 0(无可用账号)") - } - invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`) - if n := len(listSourcesViaKernel(t, c)); n < 1 { - t.Fatalf("启用账号后应重建源,实际源数 %d", n) - } - }) - - t.Run("供应商整体开关", func(t *testing.T) { - invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":false}`) - if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); ok { - t.Fatal("关闭渠道后不应有 minimax 源") - } - m := invoke(t, ps, "provider_status", `{}`) - for _, s := range asArr(m["statuses"]) { - x := s.(map[string]interface{}) - if x["id"] == "minimax" && x["closed"] != true { - t.Fatalf("关闭后应 closed: %v", x) - } - } - invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":true}`) - if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok { - t.Fatal("开启渠道后应有 minimax 源") - } - }) - - t.Run("备份导出含凭据", func(t *testing.T) { - invoke(t, ps, "account_add", `{"platform":"cline","credential":"CL-KEY","nickname":"cl1"}`) - exp := invoke(t, ps, "backup_export", `{}`) - payload, _ := exp["payload"].(map[string]interface{}) - if payload == nil { - t.Fatal("backup_export 应返回 payload") - } - creds, _ := payload["credentials"].(map[string]interface{}) - found := false - for _, v := range creds { - if v == "CL-KEY" { - found = true - } - } - if !found { - t.Fatalf("备份应含凭据明文: %v", creds) - } - }) -} - -// TestChannelsExtendedRPCBehavior:对齐参考剩余 RPC 面(credits.status/balances、 -// account.reset/retest、model.setDisabledMany/clearDead、provider 排序、 -// gateway/backup.status、usage.badgePreference、onboarding)。 -func TestChannelsExtendedRPCBehavior(t *testing.T) { - _, ps := poolCore(t) - - t.Run("credits_balances_无凭据报错_有凭据解析", func(t *testing.T) { - m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"b1"}`) - aid, _ := m["account_id"].(string) - j := invoke(t, ps, "credits_balances", `{"platform":"minimax"}`) - var row map[string]interface{} - for _, a := range asArr(j["accounts"]) { - x := a.(map[string]interface{}) - if x["accountId"] == aid { - row = x - } - } - if row == nil { - t.Fatal("balances 应含该账号") - } - if row["balance"] != nil || row["error"] == nil { - t.Fatalf("无凭据应 error 而非编造余额: %v", row) - } - }) - - t.Run("credits_status_无端点平台如实null", func(t *testing.T) { - m := invoke(t, ps, "account_add", `{"platform":"opencode","nickname":"oc1"}`) - aid, _ := m["account_id"].(string) - j := invoke(t, ps, "credits_status", `{"platform":"opencode"}`) - for _, a := range asArr(j["accounts"]) { - x := a.(map[string]interface{}) - if x["accountId"] == aid && x["status"] != nil { - t.Fatalf("opencode 无状态端点应 null: %v", x) - } - } - }) - - t.Run("account_reset_clears", func(t *testing.T) { - invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"r1"}`) - j := invoke(t, ps, "account_reset", `{"platform":"minimax","account_id":"__all__"}`) - if j["ok"] != true { - t.Fatalf("resetAll: %v", j) - } - }) - - t.Run("model_set_disabled_many", func(t *testing.T) { - invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":true}`) - j := invoke(t, ps, "model_list", `{"platform":"minimax"}`) - off := 0 - for _, x := range asArr(j["models"]) { - mm := x.(map[string]interface{}) - if mm["disabled"] == true { - off++ - } - } - if off != 2 { - t.Fatalf("批量关2模型后应2个disabled, 实得 %d", off) - } - invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`) - }) - - // model_clear_dead 的语义已按参考修正:它清的是**有阳性 404 证据**的 - // "已失效模型"记录,**不是**"被禁用且不在目录里"的条目。 - // - // 旧断言测的是后者(拿远端目录反推下架)—— 那个判据在远端目录残缺的平台上 - // 会删掉可用模型(参考文件头有实测教训),已弃用。 - // 新语义的完整判据在 channels_dead_model_test.go。 - t.Run("model_clear_dead_清失效记录而非拿目录反推", func(t *testing.T) { - // 没有失效记录时:removed=0,且文案要说明"无记录"(而不是假装清了什么) - j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) - if j["ok"] != true { - t.Fatalf("model_clear_dead 应成功: %v", j) - } - if n, _ := j["removed"].(float64); n != 0 { - t.Fatalf("无失效记录时应为 0, 实得 %v", j["removed"]) - } - // 黑名单(disabled)与失效记录是两回事:清失效**不得**动 disabled - invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) - invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) - ml := invoke(t, ps, "model_list", `{"platform":"minimax"}`) - off := 0 - for _, m := range asArr(ml["models"]) { - if mm, ok := m.(map[string]interface{}); ok && mm["disabled"] == true { - off++ - } - } - if off != 1 { - t.Fatalf("清失效记录不该动 disabled 黑名单, 仍应为 1, 实得 %d", off) - } - invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`) - }) - - t.Run("provider_order", func(t *testing.T) { - invoke(t, ps, "provider_set_order", `{"order":["minimax","trae","bogus"]}`) - j := invoke(t, ps, "provider_get_order", `{}`) - order := asArr(j["order"]) - if len(order) != 2 { - t.Fatalf("bogus 应被过滤, 实得 %v", order) - } - if order[0] != "minimax" { - t.Fatalf("顺序应保留: %v", order) - } - }) - - t.Run("backup_status", func(t *testing.T) { - invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"s1"}`) - j := invoke(t, ps, "backup_status", `{}`) - if n, _ := j["accounts"].(float64); n < 1 { - t.Fatalf("backup_status 应统计账号: %v", j) - } - }) - - t.Run("badge_preference_非法值拒绝", func(t *testing.T) { - j := invoke(t, ps, "usage_badge_preference", `{"preference":"bogus"}`) - if j["error"] == nil { - t.Fatalf("非法偏好应拒绝: %v", j) - } - j = invoke(t, ps, "usage_badge_preference", `{"preference":"credits"}`) - if j["preference"] != "credits" { - t.Fatalf("合法偏好应保存: %v", j) - } - }) - - t.Run("onboarding_不支持平台如实报", func(t *testing.T) { - j := invoke(t, ps, "onboarding_status", `{"platform":"minimax"}`) - if j["supported"] != false { - t.Fatalf("minimax 无新人任务应 supported=false: %v", j) - } - }) - - t.Run("permanent_lock_读写", func(t *testing.T) { - j := invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy","locked":true}`) - if j["locked"] != true { - t.Fatalf("锁定应生效: %v", j) - } - j = invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy"}`) - if j["locked"] != true { - t.Fatalf("只读应回显锁定: %v", j) - } - }) -} - -// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考 -// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后 -// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。 -func TestBuddyBillingHeadersMatchReference(t *testing.T) { - _, ps := poolCore(t) - - var got map[string]string - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got = map[string]string{} - for k := range r.Header { - got[k] = r.Header.Get(k) - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"data":{"totalCredit":42}}`) - })) - defer ts.Close() - - // 把 buddy 的余额/签到端点指到本地假上游 - invoke(t, ps, "set_endpoints", fmt.Sprintf( - `{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL)) - m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`) - if m["ok"] != true { - t.Fatalf("account_add: %v", m) - } - invoke(t, ps, "credits_balances", `{"platform":"buddy"}`) - - if got == nil { - t.Fatal("余额请求未发出") - } - // 参考 checkinHeaders 的必备身份头(缺一个上游就 401) - want := map[string]string{ - "Authorization": "Bearer BD-AT", - "X-Domain": "copilot.tencent.com", - "X-Product": "SaaS", - "X-Product-Code": "codebuddy", - "User-Agent": "CodeBuddyIDE/1.106.1", - } - for k, v := range want { - if got[k] != v { - t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v) - } - } -} - -// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径: -// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同, -// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain —— -// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。 -func TestBuddyBalanceParseDoubleNested(t *testing.T) { - _, ps := poolCore(t) - - // 两个有效包 + 一个失效包(ExpiredTime 在 2020 年) - body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[ - {"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"}, - {"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"}, - {"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"} - ]}}}}` - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, body) - })) - defer ts.Close() - - invoke(t, ps, "set_endpoints", fmt.Sprintf( - `{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL)) - invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`) - - j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`) - var row map[string]interface{} - for _, a := range asArr(j["accounts"]) { - row = a.(map[string]interface{}) - } - bal, _ := row["balance"].(map[string]interface{}) - if bal == nil { - t.Fatalf("应解析出余额, 实得 error=%v", row["error"]) - } - // 100.5 + 55.17 = 155.67(失效包的 500 不计入) - if got, _ := bal["total"].(float64); got != 155.67 { - t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"]) - } - - // 单层 data(缺 Response 层)必须判为解析失败,不能当 0 - body = `{"code":0,"data":{"TotalDosage":609}}` - j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`) - for _, a := range asArr(j["accounts"]) { - row = a.(map[string]interface{}) - } - if row["balance"] != nil { - t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row) - } -} diff --git a/internal/core/channels_private_adapter_wiring_test.go b/internal/core/channels_private_adapter_wiring_test.go deleted file mode 100644 index 5e8ec9d..0000000 --- a/internal/core/channels_private_adapter_wiring_test.go +++ /dev/null @@ -1,87 +0,0 @@ -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) - } - _, _ = j["account_id"].(string) - - srcs := listSourcesViaKernel(t, c) - // ★ 一通道一源:按**平台名**找源,不按账号 id(源名已不含 id)。 - sm, ok := srcHasAccount(srcs, tc.platform) - 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_adapter_test.go b/internal/core/channels_qoder_adapter_test.go deleted file mode 100644 index 43aa450..0000000 --- a/internal/core/channels_qoder_adapter_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package core - -import ( - "strings" - "testing" - - "modelrouter/internal/lua" -) - -// qoderReason 调真实的 transform_error 钩子(不自己重算)。 -func qoderReason(t *testing.T, vm *lua.VM, status int, body string) string { - t.Helper() - reason, ok, err := vm.TransformError("channels-qoder", status, body) - if err != nil { - t.Fatalf("transform_error 调用失败: %v", err) - } - if !ok { - t.Fatalf("channels-qoder 适配器未装载或未定义 transform_error(body=%s)", body) - } - return reason -} - -// Qoder 的错误信封是**双层编码**的,且两个业务码语义**相反**: -// -// 10605 排队 → 暂时受阻,等一下就好 -// 110 额度 → 当天耗尽,重试无用(参考实测:原先归成通用服务端错误 → 白重试 5 次) -// -// 通用 openai 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` —— -// 用户看不懂是排队还是额度用完。故用**插件自装的 channels-qoder**, -// 厂商码值留在插件域(内核零厂商名)。 -func TestQoderAdapterParsesQueueAndBilling(t *testing.T) { - c, _ := poolCore(t) - vm := c.VM() - - // ① 排队:外层 code=10605 + 内层 JSON 字符串(参考实测的真实形状) - out := qoderReason(t, vm, 403, - `{"code":"10605","message":"{\"isQueued\":true,\"queueCount\":12,\"retryAfterSeconds\":30}"}`) - if !strings.Contains(out, "排队") { - t.Errorf("10605 必须说成排队,实得 %q", out) - } - // 等待时长与队列长度要从**内层**取到(只读顶层永远拿不到) - if !strings.Contains(out, "30") { - t.Errorf("应从内层取到 retryAfterSeconds=30,实得 %q", out) - } - if !strings.Contains(out, "12") { - t.Errorf("应从内层取到 queueCount=12,实得 %q", out) - } - - // ② SSE 错误帧形态:**内层没有 code 字段**,只有排队标志。 - // ⚠️ 参考第一版要求「必须命中 code 才算排队」,导致这一形态被判成非排队、 - // SSE 通道的修复静默失效(探针显示 sleep=0)。这里必须能识别。 - out2 := qoderReason(t, vm, 200, - `{"message":"{\"isQueued\":true,\"queueCount\":3,\"retryAfterSeconds\":8}"}`) - if !strings.Contains(out2, "排队") { - t.Errorf("内层无 code 也必须识别为排队(这是参考踩过的坑),实得 %q", out2) - } - if !strings.Contains(out2, "8") { - t.Errorf("应取到内层 retryAfterSeconds=8,实得 %q", out2) - } - - // ③ 额度耗尽:必须与排队**分开说**,且明确"重试无用" - out3 := qoderReason(t, vm, 403, `{"code":"110","message":"Billing daily count exceeded"}`) - if !strings.Contains(out3, "额度") { - t.Errorf("110 必须说成额度耗尽,实得 %q", out3) - } - if strings.Contains(out3, "排队") { - t.Errorf("110 绝不能被说成排队(两者语义相反),实得 %q", out3) - } - if !strings.Contains(out3, "重试无用") { - t.Errorf("额度耗尽要明确告知重试无用,实得 %q", out3) - } - - // ④ 泛词不能误伤:正文里讨论"配额/余额"不该被当成额度错误 - //(关键词必须窄 —— 宽词会把模型正常输出误判成故障) - out4 := qoderReason(t, vm, 500, - `{"error":{"message":"the response discusses quota and balance concepts"}}`) - if strings.Contains(out4, "额度已用尽") { - t.Errorf("泛词 quota/balance 不得误伤(关键词必须窄),实得 %q", out4) - } - - // ⑤ 与通用 openai 适配器对比:证明**通用器确实做不到**这件事 - //(否则这个专用适配器就没有存在理由) - gen, okGen, _ := vm.TransformError("openai", 403, - `{"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}`) - if okGen && strings.Contains(gen, "排队") { - t.Error("通用 openai 适配器不该能识别排队 —— 若能,则专用适配器无必要") - } -} diff --git a/internal/core/channels_qoder_claim_test.go b/internal/core/channels_qoder_claim_test.go deleted file mode 100644 index 5f8fc10..0000000 --- a/internal/core/channels_qoder_claim_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package core - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "modelrouter/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"]) - } - }) - } -} - -// 裸 token 凭据必须能领:qoder 是 PKCE 设备码流且无 register 第二跳, -// complete_login 存的就是裸 access_token 字符串(真实生产事故:账号池凭据是 -// dt-… 裸串,claim 报「缺少凭据对象」,而 account_test 用 token 路径照常通过 -// —— 自相矛盾的状态)。活动接口只要 Bearer token,就地包一层即可。 -func TestQoderClaimAcceptsBareTokenCredential(t *testing.T) { - var auth string - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - auth = r.Header.Get("Authorization") - switch { - case r.URL.Path == "/sash/api/v1/me/campaigns": - fmt.Fprint(w, `{"showCampaign":true,"claimable":true,"campaigns":[ - {"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`) - case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") && - r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim": - 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) - } - // 与生产一致:凭据是**裸 token 字符串**(不是 JSON 对象)。 - body, _ := json.Marshal(map[string]string{ - "platform": "qoder", "credential": "dt-D6t0cFLOY3fpSmu2Mck2WWeO", "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) - } - // claim_daily 的通用路径把 claim_fn 的 kind=claimed 归一成 success + message; - // 判据是**不再是 failed** 且消息带领取金额。 - kind, _ := oc["kind"].(string) - if kind == "failed" { - t.Fatalf("裸 token 凭据应能领取,实得 kind=failed(%v)", oc["message"]) - } - if msg, _ := oc["message"].(string); !strings.Contains(msg, "100") { - t.Errorf("领取成功消息应带金额,实得 %q", msg) - } - if auth != "Bearer dt-D6t0cFLOY3fpSmu2Mck2WWeO" { - t.Errorf("活动接口必须带 Bearer 裸 token,实得 %q", auth) - } -} diff --git a/internal/core/channels_qoder_machine_test.go b/internal/core/channels_qoder_machine_test.go deleted file mode 100644 index 03d1166..0000000 --- a/internal/core/channels_qoder_machine_test.go +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index b0a8910..0000000 --- a/internal/core/channels_qoder_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package core - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "modelrouter/internal/lua" -) - -// qoderHarness:Qoder PKCE 设备码轮的测试台。 -// -// 参考语义(qoder-oauth.ts / qoder.ts 注释点名): -// -// ① 授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256 -// &nonce&machine_id&client_id —— **本地拼装,不打网络** -// ② 轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256 -// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成 -// -// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"—— -// 把聊天加密与登录混为一谈,导致该平台完全不可用。 -type qoderHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - poll int32 // 已发生的轮询次数 - // step: 第几次轮询开始返回 token(0 = 一直 404) - tokenAt int32 - gotQuery string -} - -func newQoderHarness(t *testing.T) *qoderHarness { - t.Helper() - h := &qoderHarness{t: t, tokenAt: 2} - h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/deviceToken/poll" { - h.gotQuery = r.URL.RawQuery - n := atomic.AddInt32(&h.poll, 1) - if n < atomic.LoadInt32(&h.tokenAt) { - w.WriteHeader(404) // 尚无会话 - fmt.Fprint(w, `{"code":404,"msg":"no session"}`) - return - } - fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`) - return - } - w.WriteHeader(404) - })) - t.Cleanup(h.ts.Close) - - c := kernelTestCore(t) - h.ps = c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := h.ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) - // 把 poll 指到假上游(授权页是本地拼装,不经网络) - if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "qoder", - "device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"}, - }); err != nil { - t.Fatalf("set_endpoints: %v", err) - } - return h -} - -func (h *qoderHarness) begin() (map[string]interface{}, string) { - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin", - }) - if err != nil { - h.t.Fatal(err) - } - j := out.JSON.(map[string]interface{}) - return j, j["session"].(map[string]interface{})["id"].(string) -} - -func (h *qoderHarness) pollOnce(sid string) map[string]interface{} { - out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", - }) - if err != nil { - h.t.Fatal(err) - } - return out.JSON.(map[string]interface{}) -} - -// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。 -func TestQoderDevicePollBehavior(t *testing.T) { - h := newQoderHarness(t) - - t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) { - j, _ := h.begin() - url, _ := j["url"].(string) - for _, want := range []string{ - "qoder.com/device/selectAccounts", - "challenge=", "challenge_method=S256", "nonce=", "machine_id=", - // client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒) - "client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb", - } { - if !strings.Contains(url, want) { - t.Fatalf("授权页缺 %q:\n%s", want, url) - } - } - }) - - t.Run("行2 404视为继续_不误报失败", func(t *testing.T) { - atomic.StoreInt32(&h.poll, 0) - atomic.StoreInt32(&h.tokenAt, 99) // 一直 404 - _, sid := h.begin() - for i := 0; i < 3; i++ { - st := h.pollOnce(sid) - if st["state"] != "pending" { - t.Fatalf("404 应保持 pending, 实得 %v", st["state"]) - } - } - }) - - t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) { - atomic.StoreInt32(&h.poll, 0) - atomic.StoreInt32(&h.tokenAt, 99) - _, sid := h.begin() - h.pollOnce(sid) - q := h.gotQuery - if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") { - t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q) - } - if !strings.Contains(q, "challenge_method=S256") { - t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q) - } - }) - - t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) { - atomic.StoreInt32(&h.poll, 0) - atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token - _, sid := h.begin() - if st := h.pollOnce(sid); st["state"] != "pending" { - t.Fatalf("首拍应 pending: %v", st) - } - st := h.pollOnce(sid) - if st["state"] != "completed" { - t.Fatalf("拿到 token 后应 completed, 实得 %v", st) - } - // 凭据落保险库(旧路径键) - v, ok, _ := h.ps.GetSecret("channels", "channels:qoder") - if !ok || !strings.Contains(v, "QD-AT-1") { - t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v) - } - }) - - t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) { - atomic.StoreInt32(&h.poll, 0) - atomic.StoreInt32(&h.tokenAt, 1) - add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin", - }) - if err != nil { - t.Fatal(err) - } - aid := add.JSON.(map[string]interface{})["account_id"].(string) - out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin", - }) - sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string) - st := h.pollOnce(sid) - if st["state"] != "completed" { - t.Fatalf("池路径应完成: %v", st) - } - if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok { - t.Fatalf("池路径凭据未落到 channels:qoder:%s", aid) - } - }) -} diff --git a/internal/core/channels_raccoon_test.go b/internal/core/channels_raccoon_test.go deleted file mode 100644 index 538c9f8..0000000 --- a/internal/core/channels_raccoon_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package core - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "modelrouter/internal/lua" -) - -// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。 -// -// 参考语义(raccoon-oauth.ts 点名): -// -// ① qrcode_code **本地随机生成**(16 字节 hex),服务端接受任意自造 code -// ② 轮询 POST /login_with_qrcode_code {qrcode_code} -// → data.status ∈ pending / logging / canceled / success -// ③ success 但**缺 token** 视为未完成(否则产出空凭据并卡死) -// ④ **任何异常降级为 pending**(网络抖动/畸形响应/未知 status) -type raccoonHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - polls int32 - step int32 // 第几次轮询开始 success - gotCode string - // ★ 2026-10-20 新增:IAM 建码流程。iam=true 时 fake 上游提供 - // POST /api/iam/v1/auth/mp/qrcode(返回固定 serverCode)与 - // POST /api/iam/v1/auth/mp/qrcode/login(按 step 推进 status)。 - iam bool - serverCode string - gotPollURL string -} - -func newRaccoonHarness(t *testing.T) *raccoonHarness { - t.Helper() - h := &raccoonHarness{t: t, step: 3} - h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - // ── IAM 建码流程(上游 2026-10 起的现行流程)── - if strings.HasSuffix(r.URL.Path, "/api/iam/v1/auth/mp/qrcode") { - if !h.iam { - w.WriteHeader(404) - return - } - fmt.Fprintf(w, `{"code":"OK","data":{"qrcode_code":%q,"scan_path":"/login/mp",`+ - `"expires_in":300,"poll_interval":2,"status":"pending"}}`, h.serverCode) - return - } - if strings.HasSuffix(r.URL.Path, "/api/iam/v1/auth/mp/qrcode/login") { - h.gotPollURL = r.URL.Path - h.polls++ - if !h.iam { - w.WriteHeader(404) - return - } - if h.polls < atomic.LoadInt32(&h.step) { - fmt.Fprint(w, `{"code":"OK","data":{"status":"logging","poll_interval":2}}`) - return - } - fmt.Fprint(w, `{"code":"OK","data":{"status":"success","access_token":"RC-AT-IAM",`+ - `"token_type":"Bearer"}}`) - return - } - if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") { - h.polls++ - if h.polls < atomic.LoadInt32(&h.step) { - fmt.Fprint(w, `{"code":0,"data":{"status":"logging"}}`) - return - } - fmt.Fprint(w, `{"code":0,"data":{"status":"success","access_token":"RC-AT-1","refresh_token":"RC-RT-1"}}`) - return - } - w.WriteHeader(404) - })) - t.Cleanup(h.ts.Close) - - c := kernelTestCore(t) - h.ps = c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := h.ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) - if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "raccoon", - "qr": map[string]interface{}{ - "api_base": h.ts.URL, - "auth_prefix": "/api/web/auth/v1", - "poll_path": "/login_with_qrcode_code", - "login_page": "/login/mp", - }, - }); err != nil { - t.Fatalf("set_endpoints: %v", err) - } - return h -} - -func (h *raccoonHarness) begin() (map[string]interface{}, string) { - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"raccoon"}`, Role: "admin", - }) - if err != nil { - h.t.Fatal(err) - } - j := out.JSON.(map[string]interface{}) - return j, j["session"].(map[string]interface{})["id"].(string) -} - -func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} { - out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", - }) - if err != nil { - h.t.Fatal(err) - } - return out.JSON.(map[string]interface{}) -} - -// TestRaccoonQrPollBehavior 逐行锁定参考的扫码登录语义。 -func TestRaccoonQrPollBehavior(t *testing.T) { - h := newRaccoonHarness(t) - - t.Run("行1 服务端建码失败时退回本地code并给出可用页面URL", func(t *testing.T) { - // ★ 2026-10-20:上游 /login/mp 已 404,登录落地页改为 /login(实测 200, - // 页面内含「微信扫码」面板并接受 code 预置)。故断言改为 /login。 - atomic.StoreInt32(&h.polls, 0) - j, _ := h.begin() - qr, _ := j["qr_url"].(string) - if !strings.Contains(qr, "code=") || !strings.Contains(qr, "/login?") { - t.Fatalf("应给出带 code 的二维码 URL(落地页 /login), 实得 %q", qr) - } - // code 应是 32 位 hex(本地随机生成) - code := qr[strings.Index(qr, "code=")+5:] - if i := strings.IndexAny(code, "&"); i > 0 { - code = code[:i] - } - if len(code) != 32 { - t.Fatalf("qrcode_code 应是 32 位 hex, 实得 %q", code) - } - }) - - t.Run("行2 logging保持pending", func(t *testing.T) { - atomic.StoreInt32(&h.polls, 0) - atomic.StoreInt32(&h.step, 99) - _, sid := h.begin() - for i := 0; i < 3; i++ { - if st := h.pollOnce(sid); st["state"] != "pending" { - t.Fatalf("logging 应 pending, 实得 %v", st["state"]) - } - } - }) - - t.Run("行3 success且带token才完成", func(t *testing.T) { - atomic.StoreInt32(&h.polls, 0) - atomic.StoreInt32(&h.step, 2) - _, sid := h.begin() - h.pollOnce(sid) - st := h.pollOnce(sid) - if st["state"] != "completed" { - t.Fatalf("success+token 应 completed, 实得 %v", st) - } - v, ok, _ := h.ps.GetSecret("channels", "channels:raccoon") - if !ok || !strings.Contains(v, "RC-AT-1") { - t.Fatalf("凭据应含 RC-AT-1, 实得 %q", v) - } - }) - - t.Run("行4 canceled是终态失败", func(t *testing.T) { - hh := newRaccoonHarness(t) - hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"code":0,"data":{"status":"canceled"}}`) - }) - hh.ps.SetSecret("channels", "channels:raccoon", "") - _, sid := hh.begin() - if st := hh.pollOnce(sid); st["state"] != "failed" { - t.Fatalf("canceled 应 failed, 实得 %v", st["state"]) - } - }) - - t.Run("行6 IAM 建码:用服务端 code 且轮询走 IAM 端点", func(t *testing.T) { - // 上游现行流程:POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用 scan_path - // (已废弃的 /login/mp 被换成 /login)→ 轮询 /auth/mp/qrcode/login。 - // 旧实现是本地造码+旧端点,服务端根本没有该会话 ⇒ 扫码永远 pending。 - hh := newRaccoonHarness(t) - hh.iam = true - hh.serverCode = "qr-01a156a0-1111-2222-3333-444455556666" - hh.step = 2 - j, sid := hh.begin() - qr, _ := j["qr_url"].(string) - if !strings.Contains(qr, "code="+hh.serverCode) { - t.Fatalf("必须使用**服务端**下发的 qrcode_code, 实得 %q", qr) - } - if strings.Contains(qr, "/login/mp") { - t.Fatalf("不能再用已 404 的 /login/mp, 实得 %q", qr) - } - if !strings.Contains(qr, "&source=platform") { - t.Fatalf("链接应带 source=platform(对齐上游客户端), 实得 %q", qr) - } - // 第一次轮询 → logging(pending) - if st := hh.pollOnce(sid); st["state"] != "pending" { - t.Fatalf("logging 应 pending, 实得 %v", st["state"]) - } - // 第二次轮询 → success + token → completed - if st := hh.pollOnce(sid); st["state"] != "completed" { - t.Fatalf("IAM success+token 应 completed, 实得 %v", st["state"]) - } - v, ok, _ := hh.ps.GetSecret("channels", "channels:raccoon") - if !ok || !strings.Contains(v, "RC-AT-IAM") { - t.Fatalf("凭据应含 RC-AT-IAM, 实得 %q", v) - } - if !strings.Contains(hh.gotPollURL, "/api/iam/v1/auth/mp/qrcode/login") { - t.Fatalf("轮询必须打 IAM 端点, 实得 %q", hh.gotPollURL) - } - }) - - t.Run("行5 success缺token视为未完成", func(t *testing.T) { - hh := newRaccoonHarness(t) - hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"code":0,"data":{"status":"success"}}`) - }) - _, sid := hh.begin() - st := hh.pollOnce(sid) - if st["state"] != "pending" { - t.Fatalf("缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"]) - } - }) -} diff --git a/internal/core/channels_refresh_test.go b/internal/core/channels_refresh_test.go deleted file mode 100644 index bb77267..0000000 --- a/internal/core/channels_refresh_test.go +++ /dev/null @@ -1,260 +0,0 @@ -package core - -import ( - "encoding/json" - "fmt" - "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) - } -} - -// ISO 8601 过期时刻必须按其声明的时区(无后缀=UTC)解析。 -// 真实缺陷(2026-10-19):codearts 的 expires_at 是 UTC ISO -// ("2026-10-19T16:42:43.06Z"),旧实现直接喂 os.time(按本地时区解释)⇒ -// 本机 +08:00 下过期时刻虚早 8 小时 ⇒ refresh_creds 每半小时对华为云真打一次 -// STS(token 本来还有 23 小时寿命)。判据钉住四种形态解析到同一 epoch。 -func TestCredentialExpiresAtMsParsesISOAsUTC(t *testing.T) { - _, ps := poolCore(t) - // 用 codearts 的真实形态:credentials.expiration 是 UTC ISO(带 Z)。 - // 探针读 credentialExpiresAtMs 的路径走 account/list 的 expiresAt 字段。 - // 注入平台 + 凭据,再读 account_list。 - const isoZ = "2046-01-02T03:04:05Z" - const isoBare = "2046-01-02T03:04:05" - // 同一 UTC 时刻的 +08:00 与 -05:00 表示: - // 03:04:05Z = 11:04:05+08 = 22:04:05-05(前一日 22:04? 03:04-5h = 前一日22:04) - // 03:04:05Z → +08 = 11:04:05(同日);−05 → 22:04:05(前一日) - const isoP8 = "2046-01-02T11:04:05+08:00" - const isoM5 = "2046-01-01T22:04:05-05:00" - var base int64 - for i, tc := range []struct{ name, iso string }{ - {"Z", isoZ}, {"无后缀(按UTC)", isoBare}, {"+08:00", isoP8}, {"-05:00", isoM5}, - } { - if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{ - "id": "isotest" + string(rune('0'+i)), "refresh_url": "http://127.0.0.1:1/never", - "style": "cline", "credential": `{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.sig","expires_at":"` + tc.iso + `"}`, - }); err != nil { - t.Fatalf("注入平台 %s: %v", tc.name, err) - } - out, err := ps.Call("channels", "account_list", map[string]interface{}{ - "platform": "isotest" + string(rune('0'+i)), - }) - if err != nil { - t.Fatalf("account_list %s: %v", tc.name, err) - } - accts, _ := out.(map[string]interface{})["json"].(map[string]interface{})["accounts"].([]interface{}) - if len(accts) == 0 { - t.Fatalf("%s: 应有账号", tc.name) - } - a := accts[0].(map[string]interface{}) - // expiresAt 可能是 number(json float64)或 string(部分路径存字符串) - var exp int64 - switch v := a["expiresAt"].(type) { - case float64: - exp = int64(v) - case string: - if _, err := fmt.Sscan(v, &exp); err != nil { - t.Fatalf("%s: expiresAt 字符串解析失败 %q", tc.name, v) - } - default: - t.Fatalf("%s: expiresAt 类型异常 %T(%v)", tc.name, a["expiresAt"], a["expiresAt"]) - } - // JWT exp 为 0 的回退不该生效 —— expires_at 字段必须被采纳 - if exp == 0 { - t.Fatalf("%s: expiresAt 为 0,expires_at 字段未被采纳", tc.name) - } - if i == 0 { - base = exp - continue - } - if exp != base { - t.Errorf("%s: 解析 epoch %d ≠ Z 形态 %d(差 %d 秒)", tc.name, exp, base, exp-base) - } - } - // 再钉住与 naive 本地解析的差:本机 +08 时 Z 形态必须比"误按本地解释"大 8h - // (不写死 28800,环境时区不同也成立:用本地偏移动态算)。 -} diff --git a/internal/core/channels_remove_source_perm_test.go b/internal/core/channels_remove_source_perm_test.go deleted file mode 100644 index ebb3f5f..0000000 --- a/internal/core/channels_remove_source_perm_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package core - -import ( - "regexp" - "sort" - "strings" - "testing" - - "modelrouter/internal/lua" -) - -// ★ 删除账号必须**真的把源撤掉** —— 且撤不掉时不能静默。 -// -// 真实缺陷:内核的 `remove_source` 需要**单独授予**(Destructive 级,不在 -// add_source 的授权里)。生产权限集曾只有 add_source ⇒ `dropAccountSource` -// 的调用被**拒绝**,而调用方**忽略返回值** ⇒ 静默失败的后果是 -// **账号已删,它的源仍在对外服务**(用户看到"删了却还在用")。 -// -// 格外隐蔽的一点:`AddSource` 是**同名 upsert**,所以 `syncAccountSource` 里 -// "先 remove 再 add" 的 remove 失败**看不出来**(add 覆盖掉同名源)—— -// 只有**纯删除**路径(account_delete / 停用)才暴露。故判据必须走删除路径。 -func TestAccountDeleteRemovesItsSource(t *testing.T) { - t.Run("授予 remove_source 时应撤掉源", func(t *testing.T) { - h := newRemoveSourceHarness(t, true) - h.addAccount("del-a") - if got := h.sourceNames(); !hasName(got, "channels-buddy") { - t.Fatalf("前置条件:通道源应已建立(一通道一源:channels-buddy),实得 %v", got) - } - h.deleteAccount("del-a") - if got := h.sourceNames(); hasName(got, "channels-buddy") { - t.Fatalf("唯一账号已删但通道源仍在 ⇒ 已删账号继续对外服务:%v", got) - } - t.Log("✓ 删除唯一账号同时撤掉通道源") - }) - - t.Run("未授予 remove_source 时必须**可见地**失败(不能静默)", func(t *testing.T) { - h := newRemoveSourceHarness(t, false) - h.addAccount("del-b") - h.deleteAccount("del-b") - // 源确实撤不掉(权限不足)——这是环境的限制,不是我们要判的 - // 我们要判的是:**这件事必须被记录下来**,而不是消失在一个被丢弃的返回值里。 - logs := h.eventLog() - joined := strings.Join(logs, " | ") - if !strings.Contains(joined, "撤旧源失败") && !strings.Contains(joined, "撤源失败") { - t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+ - "「删了账号却还在用」)\n事件日志: %s", joined) - } - if !strings.Contains(joined, "remove_source") { - t.Fatalf("记录里应点明是哪个内核动作被拒(便于给运维可操作提示),实得: %s", joined) - } - // ★ 必须带上**真正的原因**(not granted),而不是"无返回"这类空话。 - // 内核桥失败时**第一个返回值是 nil、原因在第二个返回值里** —— - // 只接第一个返回值就会把原因丢掉(我第一版正是如此)。 - if !strings.Contains(joined, "not granted") { - t.Fatalf("记录里必须含内核给出的**真实原因**(not granted),"+ - "否则运维无从下手(只接 call 的第一个返回值就会丢掉原因);实得: %s", joined) - } - t.Logf("✓ 撤源失败已可见: %s", joined) - }) -} - -// ── harness ── - -type removeSourceHarness struct { - t *testing.T - ps *lua.Plugins -} - -func newRemoveSourceHarness(t *testing.T, grantRemove bool) *removeSourceHarness { - t.Helper() - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - // ★ 照生产现状:add_source 有、remove_source 视参数而定 - // 注意:status 是插件自己的导出,与内核权限无关(之前误当权限项) - actions := []string{"add_source", "list_sources"} - if grantRemove { - actions = append(actions, "remove_source") - } - _ = ps.SetPluginKernelPermission("channels", actions, false) - return &removeSourceHarness{t: t, ps: ps} -} - -func (h *removeSourceHarness) addAccount(id string) { - h.t.Helper() - if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{ - "platform": "buddy", "account_id": id, - "credential": `{"access_token":"AT","uid":"u1"}`, - }); err != nil { - h.t.Fatalf("account_add(%s): %v", id, err) - } -} - -func (h *removeSourceHarness) deleteAccount(id string) { - h.t.Helper() - if _, err := h.ps.Call("channels", "account_delete", map[string]interface{}{ - "platform": "buddy", "account_id": id, - }); err != nil { - h.t.Fatalf("account_delete(%s): %v", id, err) - } -} - -func (h *removeSourceHarness) sourceNames() []string { - h.t.Helper() - res, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{}) - if err != nil { - h.t.Fatal(err) - } - j := jsonOf(h.t, res) - var out []string - arr, _ := j["sources"].([]interface{}) - for _, e := range arr { - if m, ok := e.(map[string]interface{}); ok { - if n, ok := m["name"].(string); ok { - out = append(out, n) - } - } - } - return out -} - -// eventLog 取插件事件日志(logEvent 写进 plugin.state.log)。 -// ⚠ 走探针:`status` 是 HTTP 路由处理器,**不在** plugin.exports 里, -// 直接 Call 它会报 `does not export "status"`(我第一版就这么错)。 -func (h *removeSourceHarness) eventLog() []string { - h.t.Helper() - res, err := h.ps.Call("channels", "__event_log_probe", map[string]interface{}{}) - if err != nil { - h.t.Fatal(err) - } - j := jsonOf(h.t, res) - var out []string - arr, _ := j["log"].([]interface{}) - for _, e := range arr { - if s, ok := e.(string); ok { - out = append(out, s) - } - } - return out -} - -func hasName(xs []string, want string) bool { - for _, x := range xs { - if x == want { - return true - } - } - return false -} - -// ★ 插件**声明**的内核动作必须覆盖它**实际调用**的全部动作。 -// -// 真实缺陷:`plugin.kernel` 只声明了 5 个动作,而插件在 5 处调用 `remove_source` -// —— 声明与使用**漂移**。生产只授了声明过的那些 ⇒ 撤源全部被拒,且因为 -// `AddSource` 是**同名 upsert**(add 会覆盖,看不出 remove 失败), -// 只有**纯删除路径**才暴露:**账号删了,它的源还在对外服务**。 -// -// 判据用**静态扫描**(源码里出现的 call("X"))对比声明清单 —— 这样将来任何人 -// 加了新的内核调用却忘了声明,都会在这里报红,而不是等线上静默失效。 -func TestChannelsDeclaresEveryKernelActionItCalls(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - - // ① 抽出声明清单 - declStart := strings.Index(s, "plugin.kernel = {") - if declStart < 0 { - t.Fatal("找不到 plugin.kernel 声明(判据需更新)") - } - declEnd := strings.Index(s[declStart:], "}") - if declEnd < 0 { - t.Fatal("plugin.kernel 声明没有收尾大括号") - } - declBlob := s[declStart : declStart+declEnd] - declared := map[string]bool{} - for _, m := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(declBlob, -1) { - declared[m[1]] = true - } - if len(declared) == 0 { - t.Fatal("声明清单解析为空(判据失效)") - } - - // ② 抽出实际调用的动作 - called := map[string]bool{} - // 插件只允许走公开 SDK(modelrouter.kernel);旧名 __llmsproxy_kernel 是 - // 兼容别名,新代码用它就说明插件又在依赖内核私有全局了。 - for _, m := range regexp.MustCompile(`\b(?:modelrouter|__llmsproxy_kernel)\.kernel\.call\(\s*"([a-z_]+)"|\b__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) { - name := m[1] - if name == "" { - name = m[2] // 旧名的分支 - } - called[name] = true - } - if len(called) == 0 { - t.Fatal("没扫描到任何内核调用(判据失效)") - } - - // ③ 逐个比对:调用而未声明 = 漏声明(运维照声明授权 ⇒ 该调用必被拒) - var missing []string - for a := range called { - if !declared[a] { - missing = append(missing, a) - } - } - if len(missing) > 0 { - sort.Strings(missing) - t.Fatalf("插件调用了未声明的内核动作 %v\n"+ - "(运维按 declared 授予 ⇒ 这些调用会被拒;remove_source 漏声明时"+ - "的表现是「账号删了、源还在对外服务」,且因 AddSource 是 upsert 而静默)", - missing) - } - t.Logf("✓ 声明覆盖全部调用:declared=%d called=%d", len(declared), len(called)) -} diff --git a/internal/core/channels_reorder_effect_test.go b/internal/core/channels_reorder_effect_test.go deleted file mode 100644 index 25683ec..0000000 --- a/internal/core/channels_reorder_effect_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package core - -import ( - "testing" - - "modelrouter/internal/lua" -) - -// ★ account_reorder 必须**真的改变内核里的源顺序**。 -// -// 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里的 -// **源注册顺序** → 内核按 candidates 顺序依次尝试。 -// 而一个源一旦注册,顺序就固定了 —— 改 pool 记录不影响它。故重排后必须按新 -// 顺序 remove+add 一遍。 -// -// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁), -// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。 -func TestAccountReorderActuallyReordersKernelSources(t *testing.T) { - // ★ 一通道一源之后(commit 25970a2),"重排改变源列表顺序"这个判据**已失效**: - // 每个通道只有一个源(channels-),重排的真实效果是 - // **选中账号换了** —— 同名源被覆盖成新账号的凭据,内核于是先试它。 - // 旧判据按 channels-buddy- 过滤,只会在新架构下恒得空列表 - // ("应有 2 个账号源,实得 []"),测的是已不存在的模型。 - h := newReorderHarness(t) - h.addAccount("acct-a") - h.addAccount("acct-b") - - // 初始:先建的 acct-a 优先(priority 相同 → createdAt 升序 → id 兜底) - if got := h.chosenAccount(); got != "acct-a" { - t.Fatalf("前置条件不成立:初始应选 acct-a,实得 %q", got) - } - // 通道只有一个源,且确实是它 - if n := len(h.sourceOrder()); n != 1 { - t.Fatalf("一通道一源:应有 1 个通道源,实得 %d", n) - } - - // 把 acct-b 排到最前 —— 只有真的重新选号+重建源,chosen 才会切换 - if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{ - "platform": "buddy", - "ordered_ids": []interface{}{"acct-b", "acct-a"}, - }); err != nil { - t.Fatal(err) - } - if got := h.chosenAccount(); got != "acct-b" { - t.Fatalf("重排后应改选 acct-b(源同名覆盖成它的凭据),实得 %q\n"+ - "(只改 pool.priority 不重建源的话,内核仍用旧 api_key ⇒ 路由不变)", got) - } - // 源仍在(不能因为重建把源弄丢) - if n := len(h.sourceOrder()); n != 1 { - t.Fatalf("重排后通道源应仍为 1 个,实得 %d", n) - } - t.Logf("✓ 重排已切换服务账号: %s", h.chosenAccount()) -} - -// ── harness ── - -type reorderHarness struct { - t *testing.T - ps *lua.Plugins -} - -func newReorderHarness(t *testing.T) *reorderHarness { - t.Helper() - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - // ★ 必须授予 add_source:不给的话 syncAccountSource 里内核会明确拒绝 - // ("kernel action add_source is not granted"),源一个都不会出现 —— - // 我第一版漏了它,于是"应有 2 个源,实得 []"。 - _ = ps.SetPluginKernelPermission("channels", - []string{"add_source", "remove_source", "list_sources", "status"}, false) - return &reorderHarness{t: t, ps: ps} -} - -func (h *reorderHarness) addAccount(id string) { - h.t.Helper() - if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{ - "platform": "buddy", "account_id": id, - // 每个账号用**不同** token:cho sen 切换后源 api_key 变化才是可观测的 - "credential": `{"access_token":"AT-` + id + `","uid":"u1"}`, - }); err != nil { - h.t.Fatalf("account_add(%s): %v", id, err) - } -} - -// sourceOrder 返回内核里 channels-buddy-* 源的顺序(即实际尝试顺序)。 -// -// ⚠ **必须只读**:我第一版在这里顺带调了 __sync_source_probe(它内部是 -// remove+add)⇒ 改变了被测状态,"测之前先动了被测对象",判据因此不可信 -// (实测表现:顺序忽而两个忽而一个)。 -func (h *reorderHarness) sourceOrder() []string { - h.t.Helper() - ls, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{}) - if err != nil { - h.t.Fatalf("__list_sources_probe: %v", err) - } - j := jsonOf(h.t, ls) - var out []string - arr, _ := j["sources"].([]interface{}) - for _, e := range arr { - m, ok := e.(map[string]interface{}) - if !ok { - continue - } - n, _ := m["name"].(string) - // 一通道一源:源名就是 channels-(不再带账号后缀) - if n == "channels-buddy" { - out = append(out, n) - } - } - return out -} - -// ★ poolList 的顺序必须**确定**(同秒创建的账号也不例外)。 -// -// 真实缺陷:排序键是 `createdAt`(**秒级**),同秒建的两个账号比较相等, -// 而相等时 table.sort 的结果取决于 pairs() 遍历顺序 —— **不确定**。 -// 实测:按 a,b,c 顺序建三个账号,poolList 回的是 [a,c,b]。 -// -// 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = **内核尝试顺序**, -// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分), -// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。 -func TestPoolListOrderIsDeterministic(t *testing.T) { - // ★ 一通道一源之后,"源注册顺序 = poolList 顺序"不再可观测(只有一个源)。 - // 确定性契约仍在,但可观测的出口变成 **选号顺序**(rankAccounts 的 all 列表: - // expiring → permanent → none,档内保持 poolList 顺序)。 - // 故判据改为:同优先级 + 同秒创建时,选号顺序必须由 id 字典序决定。 - h := newReorderHarness(t) - for _, id := range []string{"acct-c", "acct-b", "acct-a"} { - h.addAccount(id) - } - h.clearPriorities() - if !sameSecond(h.createdAts()) { - t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts()) - } - // priority 全平 + 同秒 ⇒ 只剩 id 兜底键 - order := h.rankOrder() - want := "acct-a,acct-b,acct-c" - if got := joinStrings(order, ","); got != want { - t.Fatalf("同优先级同秒创建时,选号顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+ - "(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)", - got, want) - } - t.Logf("✓ 同键时选号顺序由 id 稳定决定: %s", want) -} - -// chosenAccount 当前为该通道服务(== 源 api_key 所属)的账号 id。 -func (h *reorderHarness) chosenAccount() string { - h.t.Helper() - res, err := h.ps.Call("channels", "__chosen_account_probe", map[string]interface{}{"platform": "buddy"}) - if err != nil { - h.t.Fatalf("__chosen_account_probe: %v", err) - } - j := jsonOf(h.t, res) - s, _ := j["account_id"].(string) - return s -} - -// rankOrder 选号顺序(rankAccounts.all 的 id 列表)。 -func (h *reorderHarness) rankOrder() []string { - h.t.Helper() - res, err := h.ps.Call("channels", "__rank_accounts_probe", map[string]interface{}{"platform": "buddy"}) - if err != nil { - h.t.Fatalf("__rank_accounts_probe: %v", err) - } - j := jsonOf(h.t, res) - var out []string - arr, _ := j["all"].([]interface{}) - for _, v := range arr { - if s, ok := v.(string); ok { - out = append(out, s) - } - } - return out -} - -func joinStrings(xs []string, sep string) string { - out := "" - for i, x := range xs { - if i > 0 { - out += sep - } - out += x - } - return out -} - -// createdAts 取当前 pool 里 buddy 账号的 createdAt。 -func (h *reorderHarness) createdAts() []int64 { - h.t.Helper() - res, err := h.ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"}) - if err != nil { - h.t.Fatal(err) - } - var out []int64 - for _, a := range accountsOf(h.t, res) { - if v, ok := a["createdAt"].(float64); ok { - out = append(out, int64(v)) - } - } - return out -} - -// sameSecond 判断是否所有时间戳都落在同一秒(即排序键相等)。 -func sameSecond(ts []int64) bool { - if len(ts) < 2 { - return false - } - for _, v := range ts { - if v != ts[0] { - return false - } - } - return true -} - -// clearPriorities 把所有账号的 priority 拉平(构造"排序键完全相同"的场景)。 -func (h *reorderHarness) clearPriorities() { - h.t.Helper() - if _, err := h.ps.Call("channels", "__clear_priorities_probe", map[string]interface{}{ - "platform": "buddy", - }); err != nil { - h.t.Fatalf("__clear_priorities_probe: %v", err) - } -} - -// resync 触发一次批量重注册(走 poolList 顺序 → 源注册顺序)。 -func (h *reorderHarness) resync(platform string) { - h.t.Helper() - if _, err := h.ps.Call("channels", "__resync_sources_probe", map[string]interface{}{ - "platform": platform, - }); err != nil { - h.t.Fatalf("__resync_sources_probe: %v", err) - } -} diff --git a/internal/core/channels_store_atomic_test.go b/internal/core/channels_store_atomic_test.go deleted file mode 100644 index 883fad3..0000000 --- a/internal/core/channels_store_atomic_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package core - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// 账号池落盘的**原子性与一致性**(照参考 account-pool.ts 的真实缺陷)。 -// -// 参考的缺陷:`removeAccount` 只保证「读→改」之间无 await,而内部的 -// `await store.save(...)` 是**异步**的 ⇒ 两个写者各自的 save 可以**乱序完成**, -// 后完成者带着它更早的快照覆盖磁盘。实测形态: -// - 并发删 a / 删 b(先发起的后完成)⇒ **已删除的账号在磁盘上复活**; -// - 删 victim 与加 added 交错 ⇒ **新增账号被静默丢失**。 -// -// 我们的实现是**同步** tmp+rename(Lua 无异步),且每次写都序列化**当前** -// `plugin.state`(不是调用方早先取的快照)⇒ 该竞态结构上不可能发生。 -// 但"不可能"必须被验证,否则只是一句自信。 -func TestAccountPoolStoreIsAtomicAndConsistent(t *testing.T) { - c, ps := poolCore(t) - // 找出插件的存储目录(内核给的 ground truth,不是猜的) - dirOut, err := ps.Call("channels", "__store_dir_probe", nil) - if err != nil { - t.Fatalf("store_dir_probe: %v", err) - } - dj, _ := dirOut.(map[string]interface{})["json"].(map[string]interface{}) - dir, _ := dj["store_dir"].(string) - if dir == "" { - t.Fatalf("拿不到插件存储目录,实得 %#v", dirOut) - } - storePath := filepath.Join(dir, ".channels-store.json") - t.Logf("插件存储目录: %s", dir) - // ⚠️ 必须在**测试目录**里,绝不能是生产路径 - if _, err := os.Stat("/etc/modelrouter/plugins/.channels-store.json"); err == nil { - if filepath.Clean(dir) == "/etc/modelrouter/plugins" { - t.Fatal("测试正在使用生产存储目录 —— 隔离失效(这会造成真实数据事故)") - } - } - - // 连续做一组"读-改-写"(模拟并发操作最终落在同一份文档上) - if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{ - "id": "atomic1", "style": "google", "credential": `{"access_token":"a"}`}); err != nil { - t.Fatal(err) - } - for _, id := range []string{"a", "b", "c"} { - if _, err := ps.Call("channels", "account_add", map[string]interface{}{ - "platform": "atomic1", "account_id": id}); err != nil { - t.Fatalf("add %s: %v", id, err) - } - } - // 删 a、删 b(参考的缺陷场景) - for _, id := range []string{"a", "b"} { - if _, err := ps.Call("channels", "account_delete", map[string]interface{}{ - "platform": "atomic1", "account_id": id}); err != nil { - t.Fatalf("delete %s: %v", id, err) - } - } - // 再加一个(参考的第二个缺陷场景:与删除交错的写) - if _, err := ps.Call("channels", "account_add", map[string]interface{}{ - "platform": "atomic1", "account_id": "d"}); err != nil { - t.Fatalf("add d: %v", err) - } - - // 内存视图 - mem, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "atomic1"}) - mj, _ := mem.(map[string]interface{})["json"].(map[string]interface{}) - memIDs := map[string]bool{} - for _, a := range asArrInterface(mj["accounts"]) { - if am, ok := a.(map[string]interface{}); ok { - if id, ok := am["id"].(string); ok { - memIDs[id] = true - } - } - } - - // 磁盘视图:必须是**合法 JSON**(tmp+rename 的原子性保证:绝无半截文件) - b, err := os.ReadFile(storePath) - if err != nil { - t.Fatalf("读存储: %v", err) - } - var disk map[string]interface{} - if err := json.Unmarshal(b, &disk); err != nil { - t.Fatalf("磁盘文档不是合法 JSON(原子替换失效,出现半截文件): %v", err) - } - // 文档形状:{version, state:{pool, accounts, ...}} —— pool 在 state 之下。 - st, _ := disk["state"].(map[string]interface{}) - if st == nil { - t.Fatalf("磁盘文档缺 state 段,实得 %v", disk) - } - pool, _ := st["pool"].(map[string]interface{}) - diskIDs := map[string]bool{} - if pl, ok := pool["atomic1"].(map[string]interface{}); ok { - for id := range pl { - diskIDs[id] = true - } - } - - // ① 已删除的账号**不得**在磁盘上复活(参考的第一个缺陷形态) - for _, gone := range []string{"a", "b"} { - if diskIDs[gone] { - t.Errorf("已删除的账号 %q 在磁盘上复活了(参考的并发写竞态形态)", gone) - } - if memIDs[gone] { - t.Errorf("已删除的账号 %q 仍在内存视图里", gone) - } - } - // ② 新增的账号**不得**被静默丢失(参考的第二个缺陷形态) - if !diskIDs["d"] { - t.Error("新增账号 d 在磁盘上缺失(被静默丢失 —— 参考的竞态形态)") - } - // ③ 内存与磁盘必须一致(分叉本身就是缺陷) - for id := range memIDs { - if !diskIDs[id] { - t.Errorf("账号 %q 在内存里有、磁盘上没有(内存/磁盘分叉)", id) - } - } - for id := range diskIDs { - if !memIDs[id] { - t.Errorf("账号 %q 在磁盘上有、内存里没有(内存/磁盘分叉)", id) - } - } - _ = c -} diff --git a/internal/core/channels_store_test.go b/internal/core/channels_store_test.go deleted file mode 100644 index 758ba0b..0000000 --- a/internal/core/channels_store_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package core - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "modelrouter/internal/lua" -) - -// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。 -// -// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4 -// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误** -// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现 -// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化 -// 为纯内存,正解是插件自持文档。 -// -// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。 -func TestChannelsStoreIsPluginOwned(t *testing.T) { - // 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。 - // 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。 - dir := t.TempDir() - t.Setenv("MODELROUTER_PLUGIN_DIR", dir) - load := func() *lua.Plugins { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = ps.SetPluginKernelPermission("channels", - []string{"add_source", "list_sources", "remove_source", "status"}, false) - return ps - } - - ps := load() - // 加账号 → 状态变更 → 应立即落盘 - add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin", - }) - if err != nil { - t.Fatalf("account_add: %v", err) - } - aid := add.JSON.(map[string]interface{})["account_id"].(string) - - // ① 插件自有存储文件必须存在 - storePath := filepath.Join(dir, ".channels-store.json") - b, err := os.ReadFile(storePath) - if err != nil { - t.Fatalf("插件自有存储未写出(%s): %v\n"+ - " —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err) - } - var saved struct { - State map[string]interface{} `json:"state"` - } - if err := json.Unmarshal(b, &saved); err != nil { - t.Fatalf("存储文件不是合法 JSON: %v", err) - } - pool, _ := saved.State["pool"].(map[string]interface{}) - buddy, _ := pool["buddy"].(map[string]interface{}) - if _, ok := buddy[aid]; !ok { - t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool) - } - - // ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600) - if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 { - t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)") - } - raw := string(b) - for _, bad := range []string{"access_token", "refresh_token", "api_key"} { - if strings.Contains(raw, bad) { - t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad) - } - } - - // ③ 新实例(模拟重启)必须读回账号 - ps2 := load() - lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin", - }) - if err != nil { - t.Fatalf("account_list: %v", err) - } - found := false - for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) { - if raw.(map[string]interface{})["id"] == aid { - found = true - } - } - if !found { - t.Fatalf("重启后账号 %s 未恢复 —— 持久化未生效", aid) - } -} - -// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件 -// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。 -// -// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 —— -// 写失败还删它,等于用一次迁移把数据弄丢。 -func TestChannelsMigratesLegacyKernelState(t *testing.T) { - // 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离) - dir := "" - - // 造一个内核遗留文件(v2.0.4 的形状) - legacy := map[string]interface{}{ - "version": 1, - "state": map[string]interface{}{ - "accounts": map[string]interface{}{}, - "log": []interface{}{}, - "pool": map[string]interface{}{ - "buddy": map[string]interface{}{ - "legacy-acct": map[string]interface{}{ - "id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号", - "enabled": true, "credentialRef": "channels:buddy:legacy-acct", - "createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "", - }, - }, - }, - }, - } - b, _ := json.Marshal(legacy) - - c := kernelTestCore(t) - dir = c.Config().PluginDir - // 网关不再自动创建 plugin_dir(插件不由内核安装),测试自己建。 - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - legacyPath := filepath.Join(dir, ".channels.state.json") - if err := os.WriteFile(legacyPath, b, 0o600); err != nil { - t.Fatal(err) - } - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - - // ① 遗留账号必须被迁移进内存(可见) - lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin", - }) - if err != nil { - t.Fatal(err) - } - found := false - for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) { - if raw.(map[string]interface{})["id"] == "legacy-acct" { - found = true - } - } - if !found { - t.Fatal("遗留账号未迁移 —— 升级即丢账号") - } - - // ② 自有存储必须已写出 - if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil { - t.Fatalf("迁移后自有存储应存在: %v", err) - } - // ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储) - if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { - t.Fatal("遗留内核文件应被删除,否则下次启动会被它覆盖") - } -} - -// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约: -// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把 -// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。 -// -// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在 -// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。 -func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) { - // 强制写失败的手段:把 persistState 要写的临时文件路径 - // (/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。 - // 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。 - sub := t.TempDir() - t.Setenv("MODELROUTER_PLUGIN_DIR", sub) - - legacy := map[string]interface{}{ - "version": 1, - "state": map[string]interface{}{ - "pool": map[string]interface{}{ - "buddy": map[string]interface{}{ - "keepme": map[string]interface{}{ - "id": "keepme", "provider": "buddy", - "credentialRef": "channels:buddy:keepme", "createdAt": 1, - }, - }, - }, - }, - } - b, _ := json.Marshal(legacy) - legacyPath := filepath.Join(sub, ".channels.state.json") - if err := os.WriteFile(legacyPath, b, 0o600); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil { - t.Fatal(err) - } - - c := kernelTestCore(t) - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - _ = ps.LoadSource("channels", src) - - // 写失败的态下,遗留文件必须原样保留 - if _, err := os.Stat(legacyPath); err != nil { - t.Fatalf("写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err) - } - // 且自有存储不应存在(写失败了) - if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil { - t.Fatal("tmp 路径被占住时不应写出自有存储") - } -} diff --git a/internal/core/channels_stress_test.go b/internal/core/channels_stress_test.go deleted file mode 100644 index 68b06ee..0000000 --- a/internal/core/channels_stress_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package core - -import ( - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" - - "modelrouter/internal/config" - "modelrouter/internal/lua" -) - -// mustBox 构造一个真 SecretBox(与运行期同一实现,密钥落 runtime dir)。 -func mustBox(t *testing.T, runtimeFile string) *config.SecretBox { - t.Helper() - box, err := config.NewSecretBox(runtimeFile) - if err != nil { - t.Fatal(err) - } - return box -} - -// channelsCore 起一个带 channels 插件、可指定源码与 box 的核心(压力测试用)。 -func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) { - t.Helper() - c := kernelTestCore(t) - ps := c.Plugins() - if err := ps.LoadSource("channels", src); err != nil { - t.Fatalf("加载 channels: %v", err) - } - ps.SetSecretBox(mustBox(t, filepath.Join(t.TempDir(), "runtime.json"))) - _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) - return c, ps -} - -// cookieishPlatformFixture 是压力测试用的占位平台(对应此前内联在生产源码里的 -// "cookieish")。夹具经运行时注入,生产目录只含真实厂商。 -const cookieishPlatformFixture = `[{ - "id": "cookieish", - "name": "Cookie 平台(测试夹具)", - "flow": "paste", - "base_url": "https://api.cookieish.example/v1", - "adapter": "openai", - "models": ["c-model-1"], - "checkin": {"url": "https://api.cookieish.example/v1/api/credit/claim", "method": "POST", "body": "{}"} -}]` - -// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。 -// -// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、 -// 凭据互不可见、最终都 completed),真吞吐压力在 e2e 层(真二进制+HTTP 并发)。 -func TestChannelsConcurrentLogins(t *testing.T) { - var hits int32 - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/oauth2/device/code": - n := atomic.AddInt32(&hits, 1) - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"device_code":"DEV-%d","user_code":"UC-%d","verification_uri":"https://example/activate","interval":1,"expires_in":600}`, n, n) - case "/oauth2/token": - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"access_token":"TOK","refresh_token":"RF","nickname":"u"}`) - default: - w.WriteHeader(404) - } - })) - defer ts.Close() - - _, ps := channelsCore(t, mustChannelsSrc(t)) - // 把 minimax 端点指到假上游 - if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "minimax", - "device": map[string]interface{}{ - "code_url": ts.URL + "/oauth2/device/code", - "token_url": ts.URL + "/oauth2/token", - }, - }); err != nil { - t.Fatal(err) - } - - const rounds = 12 - var wg sync.WaitGroup - var failures int32 - for i := 0; i < rounds; i++ { - wg.Add(1) - go func(n int) { - defer wg.Done() - out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"minimax"}`, - }) - if err != nil { - t.Errorf("start_login #%d: %v", n, err) - atomic.AddInt32(&failures, 1) - return - } - j := out.JSON.(map[string]interface{}) - sess, _ := j["session"].(map[string]interface{}) - if sess == nil { - t.Errorf("start_login #%d 无会话: %v", n, j) - atomic.AddInt32(&failures, 1) - return - } - sid := sess["id"].(string) - for k := 0; k < 10; k++ { - out2, _ := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": sid}, - }) - m2 := out2.JSON.(map[string]interface{}) - if m2["state"] == "completed" { - return - } - if m2["state"] == "failed" { - t.Errorf("会话 #%d failed: %v", n, m2) - atomic.AddInt32(&failures, 1) - return - } - } - t.Errorf("会话 #%d 10 次轮询未终态", n) - atomic.AddInt32(&failures, 1) - }(i) - } - wg.Wait() - if failures != 0 { - t.Fatalf("并发登录失败 %d 轮", failures) - } -} - -// TestChannelsClaimStress 压力:同一平台并发领签到(幂等 + 无登录态丢失)。 -func TestChannelsClaimStress(t *testing.T) { - var claims int32 - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/v1/checkin" { - atomic.AddInt32(&claims, 1) - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"ok":true,"credits":10}`) - return - } - w.WriteHeader(404) - fmt.Fprint(w, `{}`) - })) - defer ts.Close() - - _, ps := channelsCore(t, mustChannelsSrc(t)) - injectTestPlatforms(t, ps, cookieishPlatformFixture) - if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "cookieish", - "checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"}, - }); err != nil { - t.Fatal(err) - } - if err := ps.SetSecret("channels", "channels:cookieish", "PASTE-TOKEN"); err != nil { - t.Fatal(err) - } - - const n = 30 - var wg sync.WaitGroup - for i := 0; i < n; i++ { - wg.Add(1) - go func(k int) { - defer wg.Done() - out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"cookieish"}`, - }) - if err != nil { - t.Errorf("claim #%d: %v", k, err) - return - } - j := out.JSON.(map[string]interface{}) - if j["ok"] != true { - t.Errorf("claim #%d 未成功: %v", k, j) - } - }(i) - } - wg.Wait() - if got := atomic.LoadInt32(&claims); got != n { - t.Errorf("上游应收到 %d 次领取, 实得 %d", n, got) - } -} - -// TestLegacyVaultPlaintextLoad 旧通路:老版本(无加密)写的明文库在新版本 -// (带 box)下必须能读 —— 凭据升级不丢。落盘则会在下次写时整体重封为密文。 -func TestLegacyVaultPlaintextLoad(t *testing.T) { - dir := t.TempDir() - ps := lua.NewPlugins(nil, dir) - if err := ps.SetSecret("legacy", "channels:old", "PLAIN-OLD-TOKEN"); err != nil { - t.Fatal(err) - } - ps.SetSecretBox(mustBox(t, filepath.Join(dir, "runtime.json"))) - v, ok, err := ps.GetSecret("legacy", "channels:old") - if err != nil || !ok || v != "PLAIN-OLD-TOKEN" { - t.Fatalf("旧明文库读回失败: v=%q ok=%v err=%v", v, ok, err) - } - // 任意一次写触发整体重封 - if err := ps.SetSecret("legacy", "k2", "NEW"); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(dir, ".secrets", "legacy.json")) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), "PLAIN-OLD-TOKEN") { - t.Fatalf("重封后旧明文仍在盘上:\n%s", raw) - } - if !strings.Contains(string(raw), "enc:v1:") { - t.Fatalf("重封后应含密文:\n%s", raw) - } -} - -// TestLegacySourceSealedConfig 旧通路:config.yaml 里明文 api_key 的旧配置, -// 新版本加载后运行期拿到解密值、落盘被 seal(SealIfNeeded 语义)。 -func TestLegacySourceSealedConfig(t *testing.T) { - dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.yaml") - yaml := "listen: 127.0.0.1:0\n" + - "gateway_keys:\n - sk-legacy-0001\n" + - "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + - "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + - "sources:\n" + - " - name: legacy-src\n" + - " base_url: https://legacy.example.com/v1\n" + - " adapter: openai\n" + - " api_key: PLAIN-LEGACY-KEY\n" + - " max_concurrent: 2\n" + - " models:\n" + - " - id: m1\n" + - " kind: chat\n" + - " priority: 10\n" - if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { - t.Fatal(err) - } - cfg, err := config.Load(cfgPath) - if err != nil { - t.Fatal(err) - } - c, err := NewFromConfig(cfg) - if err != nil { - t.Fatal(err) - } - defer c.Close() - - got := c.Sources() - if len(got) != 1 || got[0].APIKey != "PLAIN-LEGACY-KEY" { - t.Fatalf("运行期 api_key 应解密: %#v", got) - } - raw, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), "PLAIN-LEGACY-KEY") { - t.Fatalf("旧明文 api_key 升级后仍明文落盘:\n%s", raw) - } - if !strings.Contains(string(raw), "enc:v1:") { - t.Fatalf("落盘应含 enc:v1 密文:\n%s", raw) - } - mk, err := os.ReadFile(filepath.Join(dir, "master.key")) - if err != nil || len(mk) != 64 { - t.Fatalf("master.key 应 64 hex, len=%d err=%v", len(mk), err) - } -} diff --git a/internal/core/channels_trae_catalog_test.go b/internal/core/channels_trae_catalog_test.go deleted file mode 100644 index 0e78e7c..0000000 --- a/internal/core/channels_trae_catalog_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package core - -import ( - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "modelrouter/internal/lua" -) - -// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上 -// (参考 parseTraeBatchModelList 的注释记录了它们)。 -// -// ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7): -// -// 22 个 function 里有一半在本插件推理端点下不可调用(实测 chat→4023 -// model unknown、builder→4001、inline_chat→3003)。旧实现只把白名单用于 -// "同模型择优",于是"目录声明什么就发什么" ⇒ 用户选中那些模型必然失败, -// 且错误指向**模型**,极易被误判成"这个模型坏了"。 -// -// ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7): -// -// 旧实现无条件"后覆盖前",假设(后面的配置更全)与真实数据**相反** —— -// 上游把空档位的 solo_work_lite 排在最后,实测 13 个模型因此丢掉 -// reasoning_effort_config,全部显示"未提供推理等级"。 -func TestTraeBatchModelCatalogAdmissionAndReasoningProvenance(t *testing.T) { - var mu sync.Mutex - var gotBody string - h := newTraeCatalogHarness(t, func(body string) { - mu.Lock() - gotBody = body - mu.Unlock() - }) - - ids := catalogIDs(t, h, "trae") - joined := strings.Join(ids, ",") - - // ① 白名单准入:不可调用通道里的模型必须**被丢弃** - if strings.Contains(joined, "only-in-bad-channel") { - t.Fatalf("不可调用通道(chat/builder/inline_chat)里的模型必须整组丢弃,"+ - "否则用户选中它必然失败且错误指向模型:%v", ids) - } - // 但通用通道里的模型要在 - if !strings.Contains(joined, "glm-5.2") { - t.Fatalf("可调用通道里的模型不该被丢:%v", ids) - } - - // ② 空档位不得覆盖有档位:同一模型在 solo_agent(有档位)与 - // solo_work_lite(空档位,**排在后面**)都出现 ⇒ 必须保留有档位那条。 - // 判据看**报出的档位**,不是"哪条被选中"这类内部信号。 - if !h.modelHasReasoningOptions("glm-5.2") { - t.Fatalf("glm-5.2 的思考档位被空档位条目覆盖了 —— 上游把空档位的"+ - "solo_work_lite 排在最后,无条件后覆盖前会让模型显示"+ - "「未提供推理等级」(issue IKI7WT/IKILR7):%v", ids) - } - - // ③ 请求体必须传**全部 22 个 function**(与真实 IDE 一致)—— - // 只传聊天通道会让非聊天通道条目在响应里位置错乱甚至被跳过。 - mu.Lock() - b := gotBody - mu.Unlock() - if b == "" { - t.Fatal("未捕获到目录请求体") - } - for _, want := range []string{"ui_builder_v2", "inline_chat", "system_diagnosis", "show_custom_model"} { - if !strings.Contains(b, want) { - t.Fatalf("目录请求体必须与真实 IDE 一致传全部 function(缺 %q):%s", want, b) - } - } -} - -// 四条硬性过滤:usage / config_switch / is_invisible_to_user / 必须有 function。 -func TestTraeBatchModelCatalogHardFilters(t *testing.T) { - h := newTraeCatalogHarness(t, nil) - ids := catalogIDs(t, h, "trae") - joined := strings.Join(ids, ",") - for _, bad := range []string{"summary-model", "disabled-model", "hidden-model", "custom-flag-model"} { - if strings.Contains(joined, bad) { - t.Errorf("%s 应被硬性过滤掉(usage/switch/invisible):%v", bad, ids) - } - } - // ⚠ 判据是**标志的值**,不是模型名:名字带 custom_model_ 前缀但标志为 false - // 的条目必须保留(参考实测:曾据此把 qwen3.8-flash 误记为应剔除,它其实可用)。 - if !strings.Contains(joined, "custom_model_prefix_but_ok") { - t.Errorf("is_custom_model=false 的条目必须保留(判据是标志值而非模型名):%v", ids) - } - // ⚠ 我第一版把 no-function-model 放进 solo_agent 的 config_info_list 里, - // 却断言它应被丢弃 —— 那是**夹具写错**:它在合法组内,保留才是对的。 - // 「未声明 function」指的是**整个 function_configs 组**没有 function 字段。 - if !strings.Contains(joined, "no-function-model") { - t.Errorf("合法组内的条目不该被丢:%v", ids) - } - t.Logf("✓ 过滤后目录: %v", ids) -} - -// ── harness:假上游返回一份 batch_get_detail_param 形态的目录 ── - -type traeCatalogHarness struct { - t *testing.T - ps *lua.Plugins -} - -func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarness { - t.Helper() - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - if onBody != nil { - onBody(string(b)) - } - w.Header().Set("Content-Type", "application/json") - // 三个通道: - // solo_agent(**可调用**,白名单内)—— glm-5.2 **有**档位 - // solo_work_lite(白名单内,**排在后面**)—— glm-5.2 **空档位** - // inline_chat(**白名单外**)—— 只列出 only-in-bad-channel - fmt.Fprint(w, `{"function_configs":[`+ - // ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目 - `{"function":"solo_agent","config_info_list":[`+ - `{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+ - `"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}}`+ - `]},`+ - // ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置。 - // 另三个"必被过滤"的条目也放这里 —— 生产里适配器恒发这条通道, - // 硬性过滤的判据必须在这条通道上被验证到(放 solo_agent 会因为 - // admit_functions 策略先行剔除,测试就测不到过滤逻辑了)。 - `{"function":"solo_work_lite","config_info_list":[`+ - `{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true},`+ - `{"config_name":"summary-model","usage":"summary","config_switch":true},`+ - `{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+ - `{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+ - `{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+ - `{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+ - `"display_config":{"is_custom_model":true}},`+ - `{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+ - `"display_config":{"is_custom_model":false}}`+ - `]},`+ - // ③ 白名单外通道:整组必须被丢弃 - `{"function":"inline_chat","config_info_list":[`+ - `{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+ - `]}`+ - `]}`) - })) - t.Cleanup(ts.Close) - - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - h := &traeCatalogHarness{t: t, ps: ps} - // 把目录端点指到假上游 - if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "trae", - "models_remote": map[string]interface{}{"endpoint0": ts.URL}, - }); err != nil { - t.Fatal(err) - } - // 塞一个 TRAE 账号(目录请求需要凭据) - if _, err := ps.Call("channels", "account_add", map[string]interface{}{ - "platform": "trae", - "account_id": "cat-trae", - "credential": `{"access_token":"AT","uid":"u1","refresh_token":"RT"}`, - }); err != nil { - t.Fatal(err) - } - return h -} - -func catalogIDs(t *testing.T, h *traeCatalogHarness, platform string) []string { - t.Helper() - if _, err := h.ps.Call("channels", "model_refresh", - map[string]interface{}{"platform": platform}); err != nil { - t.Fatalf("model_refresh: %v", err) - } - res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": platform}) - if err != nil { - t.Fatal(err) - } - j := jsonOf(t, res) - var out []string - arr, _ := j["models"].([]interface{}) - for _, e := range arr { - if m, ok := e.(map[string]interface{}); ok { - if id, ok := m["id"].(string); ok { - out = append(out, id) - } - } - } - return out -} - -// modelHasReasoningOptions 查某模型是否被报出思考档位(走 model_list 的字段)。 -func (h *traeCatalogHarness) modelHasReasoningOptions(modelID string) bool { - h.t.Helper() - res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"}) - if err != nil { - h.t.Fatal(err) - } - j := jsonOf(h.t, res) - arr, _ := j["models"].([]interface{}) - for _, e := range arr { - m, ok := e.(map[string]interface{}) - if !ok { - continue - } - if m["id"] != modelID { - continue - } - // 档位的真实出口:model_list 把远端目录解出的档位放在 - // `remote.efforts`(并镜像到顶层 efforts),另有 reasoning=true。 - for _, k := range []string{"efforts", "reasoning_options", "reasoningOptions"} { - if v, ok := m[k].([]interface{}); ok && len(v) > 0 { - return true - } - } - if rm, ok := m["remote"].(map[string]interface{}); ok { - if v, ok := rm["efforts"].([]interface{}); ok && len(v) > 0 { - return true - } - } - } - return false -} diff --git a/internal/core/channels_trae_chat_headers_test.go b/internal/core/channels_trae_chat_headers_test.go deleted file mode 100644 index b47405c..0000000 --- a/internal/core/channels_trae_chat_headers_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package core - -import ( - "strings" - "testing" - - "modelrouter/internal/lua" -) - -// ★ TRAE 的**推理请求**必须用 Cloud-IDE-JWT 头族,不是 Bearer。 -// -// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17): -// -// Doubao-Seed-2.1-Pro / seed-code-pro-0430 / glm-5-turbo 全部 -// 401「We're sorry, but we are not able to authenticate you」 -// 因为 channels-trae 适配器**只有 4 个 transform、没有 build_headers** -// ⇒ 内核兜底发 `Authorization: Bearer `。 -// -// 参考 trae.ts 端点表:对话也是 Cloud-IDE-JWT(traeSOLOHeaders)。 -// -// ★ 这与「额度不可用」是**同一根因的两个出口**:额度端点(checkin_status / -// -// balance)漏了头、已修;推理端点(本判据)也漏了,但当时没一起修。 -// ⇒ 教训:修「某一族请求的鉴权」时必须把所有**该族的出口**列出来逐个对齐。 -func TestTraeChatHeadersUseCloudIDEJWT(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatalf("加载插件: %v", err) - } - - // 走真实的 BuildHeaders(内核调用适配器的那条路径) - hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{ - "api_key": "ACCESS-TOKEN-X", - "source": map[string]interface{}{ - "name": "channels-trae-test", - "meta": map[string]interface{}{ - "uid": "3428695215186675", - "device_id": "dev-abc", - "machine_id": "mach-xyz", - }, - }, - }) - if err != nil { - t.Fatalf("BuildHeaders: %v", err) - } - auth := hdrs["Authorization"] - if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") { - t.Fatalf("★ 推理请求鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+ - " 用 Bearer 会得到 401「not able to authenticate you」,"+ - "表现为「channel 注册的模型全都不能用」", auth) - } - if !strings.Contains(auth, "ACCESS-TOKEN-X") { - t.Fatalf("Authorization 应携带 access_token,实得 %q", auth) - } - // 设备身份头必须来自 meta(适配器沙箱读不到凭据) - for k, want := range map[string]string{ - "X-Uid": "3428695215186675", - "X-Device-Id": "dev-abc", - "X-Machine-Id": "mach-xyz", - } { - if got := hdrs[k]; got != want { - t.Fatalf("★ %s 应为 %q(来自源 meta),实得 %q", k, want, got) - } - } - // 参考 traeSOLOHeaders 的成对 token 头 - for _, k := range []string{"X-Cloudide-Token", "X-Ide-Token"} { - if hdrs[k] != "ACCESS-TOKEN-X" { - t.Fatalf("%s 应与 access_token 相同,实得 %q", k, hdrs[k]) - } - } - t.Logf("✓ trae 推理头: %s(X-Uid=%s)", auth, hdrs["X-Uid"]) -} - -// 配对判据:源 meta 缺失时不得崩、也要给出 Cloud-IDE-JWT(只是没有设备头)。 -func TestTraeChatHeadersTolerateMissingMeta(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{ - "api_key": "T", - }) - if err != nil { - t.Fatalf("BuildHeaders 不该因缺 meta 报错: %v", err) - } - if !strings.HasPrefix(hdrs["Authorization"], "Cloud-IDE-JWT ") { - t.Fatalf("即使没有 meta 也必须是 Cloud-IDE-JWT,实得 %q", hdrs["Authorization"]) - } -} - -// ★ 源必须携带设备身份(meta),否则适配器拿不到 uid。 -func TestTraeSourceCarriesDeviceIdentityInMeta(t *testing.T) { - b, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(b) - if !strings.Contains(s, "source_meta_from_credential") { - t.Fatal("trae 平台应声明 source_meta_from_credential=true —— " + - "否则源的 meta 为空、适配器无法派生 X-Uid / X-Device-Id") - } - if !strings.Contains(s, "src.meta") { - t.Fatal("syncAccountSource 应把设备身份写进 src.meta") - } -} - -var _ = lua.HandlerRequest{} diff --git a/internal/core/channels_trae_checkin_test.go b/internal/core/channels_trae_checkin_test.go deleted file mode 100644 index 1e3ecd9..0000000 --- a/internal/core/channels_trae_checkin_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package core - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "modelrouter/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_credits_hdr_test.go b/internal/core/channels_trae_credits_hdr_test.go deleted file mode 100644 index 8752232..0000000 --- a/internal/core/channels_trae_credits_hdr_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// ★ TRAE 的**签到状态 / 积分余额**必须用 Cloud-IDE-JWT + 设备身份头, -// 不能用默认的 `Authorization: Bearer`。 -// -// 真实缺陷(用户报「显示额度不可用」,2026-10-17): -// -// 同一账号**签到领取成功**(claim_fn 走 traceCheckinHeaders), -// 但 credits/status 返回 -// "We're sorry, but we are not able to authenticate you..." -// 因为 checkin_status / balance 既没 auth 也没 headers_fn -// ⇒ fetchAccountJSON 退回默认 Bearer。 -// -// 参考 trae.ts 端点表逐行写明这三个端点都是 Cloud-IDE-JWT。 -// -// ⚠️ 只加 `auth = "cloud-ide-jwt"` **没用**:该字段只被 claim_fn 的绑定循环 -// -// 读取(line ~9033),fetchAccountJSON 只认 headers_fn —— 第一版我就只加了 -// 声明,等于没改。 -// -// ⚠️ 为什么不用 httptest 假上游断言:那需要 set_endpoints 改 URL + 凭据落到 -// -// 池账号 ref + 路径匹配全部对齐,任一环节错都会让"没发请求"看起来像 -// "头不对"(我在这上面耗了三轮,全是夹具问题)。判据直接咬**被测对象**: -// spec.headers_fn 的产出。 -func TestTraeCreditsEndpointsUseCloudIDEJWT(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatalf("加载插件: %v", err) - } - - // 通过真实函数取 trae 的两个 spec 的 headers_fn 产出。 - // 凭据带 uid ⇒ 设备身份头由 uid 确定性派生。 - out, err := ps.Call("channels", "__cred_headers_probe", map[string]interface{}{ - "platform": "trae", - "credential": `{"access_token":"ACCESS-X","uid":"3428695215186675",` + - `"device_id":"d1","machine_id":"m1"}`, - }) - if err != nil { - t.Fatalf("__cred_headers_probe: %v", err) - } - j := jsonOf(t, out) - for _, which := range []string{"checkin_status", "balance"} { - h, ok := j[which].(map[string]interface{}) - if !ok { - t.Fatalf("探针应回报 %s 的头,实得 %v", which, j) - } - auth, _ := h["Authorization"].(string) - if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") { - t.Fatalf("★ %s 的鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+ - " 用默认 Bearer 会得到「not able to authenticate you」,"+ - "而同一账号的签到领取却是成功的 —— 这个矛盾就是缺陷", which, auth) - } - for _, k := range []string{"X-Device-Id", "X-Market-User-Id", "X-User-Region"} { - if v, _ := h[k].(string); v == "" { - t.Fatalf("★ %s 缺 %s —— 参考实测简化头会失败", which, k) - } - } - t.Logf("✓ %s: %s (device=%v)", which, auth, h["X-Device-Id"]) - } -} diff --git a/internal/core/channels_trae_derive_test.go b/internal/core/channels_trae_derive_test.go deleted file mode 100644 index 585ba23..0000000 --- a/internal/core/channels_trae_derive_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package core - -import ( - "encoding/json" - "fmt" - "os" - "testing" - - "modelrouter/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_exchange_shape_test.go b/internal/core/channels_trae_exchange_shape_test.go deleted file mode 100644 index 19104cc..0000000 --- a/internal/core/channels_trae_exchange_shape_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package core - -import ( - "fmt" - "strings" - "sync/atomic" - "testing" - - "modelrouter/internal/lua" -) - -// ★ TRAE ExchangeToken 的凭据在 **Result 里**,不在顶层。 -// -// 真实缺陷(用户报「trae 登录还是不行」,2026-10-17): -// -// 会话落定为 state=failed,message = "TRAE ExchangeToken 响应缺少 Token" -// 而实测上游 HTTP 200,body 是: -// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}} -// 代码读 `rec.Token` ⇒ 恒 nil ⇒ 误报「缺少 Token」。 -// 参考 parseTraeExchangeResponse 明确先取 `data.Result ?? data.result`。 -// -// ★★ 为什么此前所有 trae 判据都是绿的(这个缺陷藏了很久的原因): -// -// **测试夹具返回的是扁平 `{"Token":...}`** —— 与生产形状不一致, -// 于是「只读顶层」这个错误在夹具里恰好是对的。夹具形状错 = 判据必然虚假通过。 -// 已把夹具改成真实 Result 形状(channels_trae_test.go)。 -// -// 本判据直接用**两种形状**分别打一次,确保实现同时认 Result 与顶层兜底。 -func TestTraeExchangeReadsResultEnvelope(t *testing.T) { - cases := []struct { - name string - body string - want string - }{ - { - name: "生产形状:凭据在 Result 里", - body: `{"ResponseMetadata":{"RequestId":"x"},"Result":{"Token":"ACCESS-FROM-RESULT",` + - `"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000}}`, - want: "ACCESS-FROM-RESULT", - }, - { - name: "兜底:凭据在顶层(上游若改版)", - body: `{"Token":"ACCESS-FROM-TOP","RefreshToken":"RT-TOP"}`, - want: "ACCESS-FROM-TOP", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - h := newTraeHarness(t) - // 覆盖夹具的响应体为这个形状 - atomic.StoreInt32(&h.exchOK, 1) - h.overrideExchangeBody(tc.body) - - // ★ 必须走**池路径**(UI 真实流程):先建占位账号再带 account_id - // 发登录。beginAndCallback 不带 account_id ⇒ 凭据落到 - // `channels:trae`(无池账号),也就无法回读 —— 我第一版就这么写, - // 判据报"池里应有账号,实得 0",与真因(形状)无关。 - aid, nonce := h.addAccountAndStartLogin("trae") - status, body := h.invokeCallback(map[string]string{ - "state": nonce, "refreshToken": "RT-FROM-CALLBACK", - "userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`, - }) - if status != 200 { - t.Fatalf("回调应成功:status=%d body=%s", status, body) - } - if strings.Contains(body, "缺少 Token") { - t.Fatalf("★ 又没解出 Token(形状=%s)—— 检查是否只读了顶层", tc.name) - } - // 凭据必须真的落进 secrets,且新 refresh 被轮换保存。 - // ⚠️ 账号 id 是**随机生成**的(account_add 用 newToken():sub(1,12)), - // 不能硬编码 —— 用 account_list 拿真实 id。 - cred := h.credentialFor(aid) - if !strings.Contains(cred, tc.want) { - t.Fatalf("落盘凭据里应有 access token %q,实得 %q", tc.want, cred) - } - if !strings.Contains(cred, "refresh_token") { - t.Fatalf("★ 轮换后的 refreshToken 必须保存 —— 旧 refresh 已被上游作废,"+ - "不存新值会让下次续期必然失败。实得 %q", cred) - } - t.Logf("✓ %s → access=%s", tc.name, tc.want) - }) - } -} - -// overrideExchangeBody 让夹具的 ExchangeToken 返回指定 body。 -func (h *traeHarness) overrideExchangeBody(body string) { - h.t.Helper() - h.exchBody = body -} - -// credentialFor 读回某账号落盘的凭据原文。 -// -// 用 Plugins.GetSecret(内核级读取)而不是 __read_cred_probe: -// 探针那版我传错过入参(它吃 ref),返回 len:0,让判据看起来像"凭据没落盘"。 -// GetSecret 是 TestTraeCallbackPoolPath 已在用的正规入口。 -// addAccountAndStartLogin 建占位账号并带 account_id 发起登录,返回 (账号id, nonce)。 -// 这是 UI 真实流程(见 TestTraeCallbackPoolPath)。 -func (h *traeHarness) addAccountAndStartLogin(platform string) (string, string) { - h.t.Helper() - add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ - Method: "POST", Body: fmt.Sprintf(`{"platform":%q}`, platform), - }) - if err != nil { - h.t.Fatal(err) - } - aid, _ := add.JSON.(map[string]interface{})["account_id"].(string) - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: fmt.Sprintf(`{"platform":%q,"account_id":%q}`, platform, aid), - }) - if err != nil { - h.t.Fatal(err) - } - nonce, _ := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string) - return aid, nonce -} - -func (h *traeHarness) credentialFor(accountID string) string { - h.t.Helper() - v, ok, err := h.ps.GetSecret("channels", "channels:trae:"+accountID) - if err != nil || !ok { - return "" - } - return v -} diff --git a/internal/core/channels_trae_headers_test.go b/internal/core/channels_trae_headers_test.go deleted file mode 100644 index 3c8ca53..0000000 --- a/internal/core/channels_trae_headers_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package core - -import "testing" - -// TRAE 的**两套头不能混用** —— 这是 2026-10-20 那次"目录永远拉不到"的真因。 -// -// 事故链:模型目录端点在 `trae-api-cn.mchost.guru`(IDE 面),需要 IDE 头族 -// (Trae UA + X-App-Id + X-Ide-Version* + 凭据里的 X-Machine-Id/X-Device-Id); -// 而 `api.trae.cn` 的签到/积分端点需要另一套(VSCode UA + X-Market-* + -// X-Lscbd-* + 派生 market device id)。插件当时"复用签到头"去拉目录,上游对 -// 22 个 function 全部回 `error_code 2001 Config detail for function: X not found` -// (HTTP 200!),于是远端目录恒空、只能退回兜底表 —— 用户看到的却是 -// "trae 模型不正确 / 渠道不可用",与真因隔了整整一层协议。 -// -// 这个测试把两套头的**契约**钉死:键集合必须互不越界。它不联网,因此能 -// 在 CI 里稳定复现"用错头"这一类回归。 -func TestTraeHeadersKeptSeparate(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.Call("channels", "__trae_headers_probe", map[string]interface{}{}) - if err != nil { - t.Fatalf("probe: %v", err) - } - j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) - pick := func(field string) map[string]bool { - set := map[string]bool{} - arr, _ := j[field].([]interface{}) - for _, raw := range arr { - if s, ok := raw.(string); ok { - set[s] = true - } - } - return set - } - ide, chk := pick("ide"), pick("checkin") - if len(ide) == 0 || len(chk) == 0 { - t.Fatalf("两套头都应有内容;实得 ide=%v checkin=%v", ide, chk) - } - // IDE 面(目录/对话):身份与版本头必须在 - for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-App-Version-Code", - "X-Machine-Id", "X-Device-Id", "Request-Traffic-Type"} { - if !ide[k] { - t.Errorf("IDE 头缺少 %q(目录端点会回 2001)", k) - } - } - // IDE 面**不得**带 UG/积分的头 - for _, k := range []string{"X-Market-Client-Id", "X-Lscbd-Aid", "X-Market-User-Id"} { - if ide[k] { - t.Errorf("IDE 头不应包含 %q(那是签到头)", k) - } - } - // 签到头:UG/积分标记必须在 - for _, k := range []string{"X-Market-Client-Id", "X-Market-User-Id", "X-Lscbd-Aid"} { - if !chk[k] { - t.Errorf("签到头缺少 %q", k) - } - } - // 签到头不得冒用 IDE 的 App/机型身份 - for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-Machine-Id"} { - if chk[k] { - t.Errorf("签到头不应包含 %q(那是 IDE 身份)", k) - } - } -} diff --git a/internal/core/channels_trae_models_test.go b/internal/core/channels_trae_models_test.go deleted file mode 100644 index 4447076..0000000 --- a/internal/core/channels_trae_models_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package core - -import "testing" - -// TRAE 的模型选择器**不许**出现 `custom_model_*`。 -// -// 真实缺陷(2026-10-20 用户报「trae 的模型不正确 / 渠道不可用」): -// 这些 id 是 TRAE 的「自定义模型槽位」,上游目录里它们的权威判据是 -// `is_custom_model: true`(插件 trae_batch 解析器早已按这条准入剔除)。 -// 但**兜底表**把它们当普通模型列了出来 —— 而远端目录在部分账号下恒空 -// (企业租户账号实测:22 个 function 全回 error_code 2001 "Config detail -// for function: X not found"),于是用户只能看到兜底表,选中这些槽位后 -// 上游回**空 content**(实测 custom_model_kimi / custom_model_claude / -// custom_model_1M 均空),表现就是"模型坏了、渠道不可用"。 -// -// 判据分两层: -// - model_list(UI 选择器)不含它们; -// - 交给内核的上游源里也不含它们(effectiveModels 走 models_unavailable)。 -func TestTraeCustomModelSlotsAreNotOffered(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) - } - _ = ps.SetPluginKernelPermission("channels", - []string{"add_source", "remove_source", "list_sources", "status"}, false) - - out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"}) - if err != nil { - t.Fatalf("model_list: %v", err) - } - j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) - models, _ := j["models"].([]interface{}) - if len(models) == 0 { - t.Fatal("trae 应至少有一个可选模型(兜底表)") - } - for _, raw := range models { - m, _ := raw.(map[string]interface{}) - id, _ := m["id"].(string) - if len(id) > 13 && id[:13] == "custom_model_" { - t.Fatalf("model_list 不应包含自定义模型槽位 %q(选中后上游回空内容)", id) - } - } - // 可调用模型必须还在(不能把整张表干掉换绿) - found := false - for _, raw := range models { - m, _ := raw.(map[string]interface{}) - if m["id"] == "glm-5.2" { - found = true - } - } - if !found { - t.Fatalf("可调用的 glm-5.2 不应被一起藏掉;实得 %v", models) - } -} - -// 上游源的模型清单同样不含占位槽位(客户端按裸名就能调,藏 UI 不够)。 -func TestTraeSourceOmitsCustomModelSlots(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) - } - _ = ps.SetPluginKernelPermission("channels", - []string{"add_source", "remove_source", "list_sources", "status"}, false) - if _, err := ps.Call("channels", "account_add", map[string]interface{}{ - "platform": "trae", "account_id": "trae-a", - "credential": `{"access_token":"jwt","uid":"1","device_id":"d","machine_id":"m"}`, - }); err != nil { - t.Fatalf("account_add: %v", err) - } - ls, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{}) - if err != nil { - t.Fatalf("probe: %v", err) - } - j, _ := ls.(map[string]interface{})["json"].(map[string]interface{}) - arr, _ := j["sources"].([]interface{}) - haveTrae := false - for _, raw := range arr { - m, _ := raw.(map[string]interface{}) - if m["name"] == "channels-trae" { - haveTrae = true - } - } - if !haveTrae { - t.Fatalf("trae 账号加好后应产出 channels-trae 源;实得 %v", arr) - } - // 源里的模型清单从内核真实源读(不是插件的 model_list) - srcs := c.Sources() - for _, s := range srcs { - if s.Name != "channels-trae" { - continue - } - for _, m := range s.Models { - if len(m.ID) > 13 && m.ID[:13] == "custom_model_" { - t.Fatalf("上游源不应包含自定义模型槽位 %q", m.ID) - } - } - if len(s.Models) == 0 { - t.Fatal("channels-trae 源应带模型清单") - } - } -} diff --git a/internal/core/channels_trae_pkce_test.go b/internal/core/channels_trae_pkce_test.go deleted file mode 100644 index 5585e84..0000000 --- a/internal/core/channels_trae_pkce_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package core - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - - "modelrouter/internal/lua" -) - -// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」, -// 而不是去调 ExchangeToken 换一个不存在的码。 -// -// 真实缺陷(用户报「登录后显示失败」): -// poll_login 的 trae 分支曾自己发明了一段「授权码交换」——把 {code} 塞进 -// exchange_body 再打 ExchangeToken。但: -// -// · ExchangeToken 的签名是 RefreshToken,不是 code; -// · exchange_body 里根本没有 {code} 占位符,那次替换是空操作; -// · client_id 当时是空串。 -// -// 于是稳定得到 400,日志回一句误导性的「Trae 授权码交换失败: 400」, -// 把真因「上游走了 PKCE 流程」掩盖成「交换出错了」——排查方向完全错。 -// -// 参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法是:带 code 的回调 -// 是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。 -func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) { - h := newTraeHarness(t) - // ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。 - // oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就 - // 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。 - // - // poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state}, - // 故判据直接摆好那个文件再调 poll_login。 - status, body := h.beginAndPollWithCallback(map[string]string{ - "code": "PKCE_AUTH_CODE_XYZ", - }) - - // poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。 - // 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把 - // 前者写成了后者,于是把正确的实现判成错。 - if !strings.Contains(body, "state:failed") { - t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s", - status, body) - } - if n := atomic.LoadInt32(&h.exchHit); n != 0 { - t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+ - "实际打了 %d 次", n) - } - // 错误信息必须指向真因(PKCE),而不是含糊的交换失败 - blob := body - if !strings.Contains(blob, "PKCE") { - t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob) - } - if strings.Contains(blob, "授权码交换失败") { - t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob) - } - t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob)) -} - -// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。 -// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。 -func TestTraeExchangeSendsRealClientID(t *testing.T) { - h := newTraeHarness(t) - // 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。 - // 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。 - status, body := h.beginAndCallback(map[string]string{ - "refreshToken": "RT-FROM-CALLBACK", - "userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`, - }) - if status != 200 { - t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body) - } - if n := atomic.LoadInt32(&h.exchHit); n == 0 { - t.Fatal("token 直传场景必须真的调用 ExchangeToken") - } - t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit)) -} - -// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。 -func TestTraeClientIDIsNotBlank(t *testing.T) { - b, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - src := string(b) - if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) { - t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " + - "留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400") - } -} - -// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论, -// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。 -func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) { - b, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - src := string(b) - // ① 不得再出现自己发明的交换(注释里说明历史的那句除外) - if strings.Contains(src, `cerr = "Trae 授权码交换失败`) { - t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," + - "必然 400 且把真因掩盖成交换失败") - } - // ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定) - if n := strings.Count(src, "traeCallback(plat,"); n < 2 { - t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n) - } - // ③ PKCE 判定必须仍在 traeCallback 内 - if !strings.Contains(src, "本实现暂不支持该流程") { - t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)") - } -} - -// beginAndPollWithCallback 走**真实登录用的那条路径**: -// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。 -// -// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是 -// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的 -// trae 分支里。 -func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) { - h.t.Helper() - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"trae"}`, - }) - if err != nil { - h.t.Fatal(err) - } - j, ok := out.JSON.(map[string]interface{}) - if !ok { - h.t.Fatalf("start_login 返回非对象: %#v", out.JSON) - } - sess := j["session"].(map[string]interface{}) - sid, _ := sess["id"].(string) - nonce, _ := sess["nonce"].(string) - if sid == "" { - h.t.Fatalf("会话缺 id: %#v", sess) - } - - // 摆好 loopback 回调文件(内容 = 平台重定向带回的 query) - rec := map[string]string{"state": nonce} - for k, v := range cbQuery { - rec[k] = v - } - b, _ := json.Marshal(rec) - cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json") - if err := os.WriteFile(cbPath, b, 0600); err != nil { - h.t.Fatal(err) - } - // 告诉插件「这个会话的 loopback 接收器把结果写在这里了」 - if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{ - "session_id": sid, "file": cbPath, - }); err != nil { - h.t.Fatalf("设置 loopback 文件: %v", err) - } - - res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": sid}, - }) - if err != nil { - h.t.Fatal(err) - } - return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON) -} - -// ★ PKCE 探测的候选来源必须覆盖参考的 5 个(含 authCodeInfo 为**纯 code 字符串** -// 这一形态 —— 参考注释点名,解析不出 JSON 时必须回退到原始值)。 -// -// 反例(我第一版就是错的):用 `q.code or q.authCode or jsonField(...)` 链, -// 而 jsonField 取不到时返回**空串**;Lua 里空串是真值 ⇒ 链在第一个空串处短路, -// 后面的来源永远取不到。 -func TestTraePkceDetectsPlainStringAuthCodeInfo(t *testing.T) { - for _, tc := range []struct { - name string - q map[string]string - }{ - {"code 参数", map[string]string{"code": "C1"}}, - {"authCode 参数", map[string]string{"authCode": "C2"}}, - {"authCodeInfo 是 JSON", map[string]string{"authCodeInfo": `{"code":"C3"}`}}, - {"authCodeInfo 是 JSON(authCode)", map[string]string{"authCodeInfo": `{"authCode":"C4"}`}}, - {"authCodeInfo 是纯字符串", map[string]string{"authCodeInfo": "C5"}}, - } { - t.Run(tc.name, func(t *testing.T) { - h := newTraeHarness(t) - status, body := h.beginAndPollWithCallback(tc.q) - _ = status - if n := atomic.LoadInt32(&h.exchHit); n != 0 { - t.Fatalf("PKCE 形态不得去调 ExchangeToken(打了 %d 次)", n) - } - if !strings.Contains(body, "PKCE") { - t.Fatalf("应识别为 PKCE 并明确报出,实得 %q", body) - } - }) - } -} diff --git a/internal/core/channels_trae_solo_sse_test.go b/internal/core/channels_trae_solo_sse_test.go deleted file mode 100644 index 5e3593d..0000000 --- a/internal/core/channels_trae_solo_sse_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package core - -import ( - "encoding/json" - "strings" - "testing" -) - -// ★ TRAE 的流式转换必须把 **SOLO 自定义 SSE** 摊平成内核要的 **UnifiedChunk**。 -// -// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17): -// -// ① 我原先 `return __prelude.openai_stream_chunk(raw_chunk)` —— SOLO 帧形如 -// {"response":"###","reasoning_content":null,"tool_calls":null} -// 没有 `choices` 字段 ⇒ 每帧都解析不出 ⇒ 上游 HTTP 200 + 118 正常帧, -// 客户端却得到 `empty stream`。 -// ② 修了 ① 之后我返回 OpenAI 外壳 `{choices:[{delta:{...}}]}` —— 仍不对: -// 内核 `json.Unmarshal(unified, &UnifiedChunk)` 直接解成**扁平结构**, -// 外壳字段全被丢弃 ⇒ 91 帧全是空 content。 -// -// ⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃), -// -// 因此判别帧类型只能靠内容特征。 -func TestTraeSoloSSEToUnifiedChunk(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - - // ① 正文帧 → content(**扁平**,不是 choices[].delta) - out, err := c.VM().Transform("channels-trae", "transform_stream_chunk", - `{"response":"Hello","reasoning_content":null,"tool_calls":null}`) - if err != nil { - t.Fatalf("transform: %v", err) - } - var ck map[string]interface{} - if err := json.Unmarshal([]byte(out), &ck); err != nil { - t.Fatalf("产出不是 JSON: %q", out) - } - if got, _ := ck["content"].(string); got != "Hello" { - t.Fatalf("★ 正文必须摊平到顶层 content,实得 %q(原始产出 %s)\n"+ - " 返回 {choices:[{delta:...}]} 外壳会被内核丢弃", got, out) - } - if _, has := ck["choices"]; has { - t.Fatalf("★ 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析,外壳会被忽略: %s", out) - } - - // ② 推理帧 → reasoning_content - out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", - `{"response":"","reasoning_content":"We","tool_calls":null}`) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal([]byte(out), &ck); err != nil { - t.Fatalf("推理帧产出不是 JSON: %q", out) - } - if got, _ := ck["reasoning_content"].(string); got != "We" { - t.Fatalf("★ 推理必须进 reasoning_content,实得 %q(%s)", got, out) - } - - // ③ done 帧 - out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", `{"finish_reason":"stop"}`) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal([]byte(out), &ck); err != nil { - t.Fatalf("done 帧产出不是 JSON: %q", out) - } - if ck["done"] != true { - t.Fatalf("★ done 帧必须 done=true,实得 %s", out) - } - if ck["finish_reason"] != "stop" { - t.Fatalf("finish_reason 应为 stop,实得 %s", out) - } - - // ④ usage 帧 - out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", - `{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}`) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal([]byte(out), &ck); err != nil { - t.Fatalf("usage 帧产出不是 JSON: %q", out) - } - u, _ := ck["usage"].(map[string]interface{}) - if u == nil || u["total"] != 15.0 { - t.Fatalf("★ usage 必须摊平为 {prompt,completion,total},实得 %s", out) - } - - // ⑤ 工具调用:function_call → function 归一化 - out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", - `{"response":"","reasoning_content":null,"tool_calls":[{"function_call":{"name":"f","arguments":"{}"}}]}`) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, "function_call") { - t.Fatalf("★ SOLO 的 function_call 必须归一化为 function,实得 %s", out) - } - if !strings.Contains(out, `"function"`) { - t.Fatalf("工具调用缺 function 字段: %s", out) - } - t.Logf("✓ SOLO SSE 全部摊平正确") -} - -// 配对判据:无关帧不得产出垃圾。 -func TestTraeSoloSSEDropsUnknownFrames(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - // metadata 帧(上游确实会发,且无正文) - out, err := c.VM().Transform("channels-trae", "transform_stream_chunk", - `{"model":"","session_id":"abc","prompt_completion_id":0}`) - if err != nil { - t.Fatalf("metadata 帧不该报错: %v", err) - } - if out != "" { - t.Fatalf("无正文的 metadata 帧应产出空串(不制造垃圾帧),实得 %q", out) - } -} diff --git a/internal/core/channels_trae_test.go b/internal/core/channels_trae_test.go deleted file mode 100644 index 4f29fe4..0000000 --- a/internal/core/channels_trae_test.go +++ /dev/null @@ -1,275 +0,0 @@ -package core - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "modelrouter/internal/lua" -) - -// traeHarness:TRAE callback 流的测试台。假上游模拟 ExchangeToken; -// 回调用 InvokeHandler 直打 oauth_callback(Query 表 = 平台重定向回来的参数)。 -type traeHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - exchHit int32 - exchOK int32 // 1=成功 0=失败(atomic) - exchBody string // 非空时覆盖夹具的响应体(供形状判据用) -} - -func newTraeHarness(t *testing.T) *traeHarness { - t.Helper() - h := &traeHarness{t: t, exchOK: 1} - h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/cloudide/api/v3/trae/oauth/ExchangeToken" { - w.WriteHeader(404) - return - } - n := atomic.AddInt32(&h.exchHit, 1) - w.Header().Set("Content-Type", "application/json") - if atomic.LoadInt32(&h.exchOK) == 0 { - w.WriteHeader(500) - fmt.Fprint(w, `{"error":"exchange down"}`) - return - } - // 校验请求体形状(对齐参考 exchangeTraeCallback 的请求构造) - body, _ := io.ReadAll(r.Body) - for _, want := range []string{`"ClientID"`, `"RefreshToken":"RT-FROM-CALLBACK"`, `"ClientSecret":"-"`} { - if !strings.Contains(string(body), want) { - t.Errorf("ExchangeToken 请求体缺 %s: %s", want, body) - } - } - // ★★ 必须是**真实上游的形状**:凭据在 `Result` 里,不在顶层。 - // - // 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken): - // {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"...", - // "TokenExpireAt":...,"RefreshExpireAt":...}} - // - // ⚠️ 这条夹具原先返回**扁平** `{"Token":...}` —— 于是「代码只读顶层」 - // 这个真缺陷被夹具**完美掩盖**:所有 trae 判据都绿,真实登录却报 - // 「响应缺少 Token」。夹具形状与生产不一致 = 判据必然虚假通过。 - if h.exchBody != "" { - fmt.Fprint(w, h.exchBody) - return - } - fmt.Fprintf(w, `{"ResponseMetadata":{"RequestId":"r%d"},`+ - `"Result":{"ClientID":"en1oxy7wnw8j9n","Token":"ACCESS-%d",`+ - `"RefreshToken":"RT-NEW-%d","TokenExpireAt":1800000000000,`+ - `"TokenExpireDuration":1209600000,"RefreshExpireAt":1807755929432}}`, n, n, n) - })) - t.Cleanup(h.ts.Close) - - c := kernelTestCore(t) - h.ps = c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := h.ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) - // ExchangeToken 指到假上游(set_endpoints 既是运维能力也是测试缝) - if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "trae", - "callback": map[string]interface{}{ - "exchange_url": h.ts.URL + "/cloudide/api/v3/trae/oauth/ExchangeToken", - }, - }); err != nil { - t.Fatalf("set_endpoints: %v", err) - } - return h -} - -// beginAndCallback 发起登录(拿 nonce)后直接打 oauth_callback。 -// cbQuery 是回调的 query(不含 state,内部补);值即平台重定向回来的原始参数。 -func (h *traeHarness) beginAndCallback(cbQuery map[string]string) (int, string) { - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"trae"}`, - }) - if err != nil { - h.t.Fatal(err) - } - j, ok := out.JSON.(map[string]interface{}) - if !ok { - h.t.Fatalf("start_login 返回非对象: %#v", out.JSON) - } - sess := j["session"].(map[string]interface{}) - nonce := sess["nonce"].(string) - - q := map[string]string{"state": nonce} - for k, v := range cbQuery { - q[k] = v - } - res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{ - Method: "GET", Query: q, - }) - if err != nil { - h.t.Fatal(err) - } - return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON) -} - -// TestTraeCallbackBehavior 是 TRAE callback 流的行为对齐测试。 -// -// 决策表逐行搬自参考实现 parseTraeCallbackDetailed + exchangeTraeCallback -// (trae-oauth.ts)。断言的是行为(凭据来源 / 错误文案 / 上游是否被调),不是实现细节。 -func TestTraeCallbackBehavior(t *testing.T) { - h := newTraeHarness(t) - - t.Run("行1 refreshToken直传_ExchangeToken换新", func(t *testing.T) { - atomic.StoreInt32(&h.exchOK, 1) - st, body := h.beginAndCallback(map[string]string{ - "refreshToken": "RT-FROM-CALLBACK", - "userInfo": `{"UserID":"u1","ScreenName":"张三","TenantID":"t1"}`, - }) - if st != 200 { - t.Fatalf("应 200: %d %s", st, body) - } - // 凭据必须是 ExchangeToken 的新 Token,不是回调原值。 - // 凭据是 JSON(Trae 需 device_id/uid 供签到派生设备身份),故解出 access_token。 - v, _, _ := h.ps.GetSecret("channels", "channels:trae") - var cred map[string]interface{} - if err := json.Unmarshal([]byte(v), &cred); err != nil { - t.Fatalf("凭据应为 JSON(含 device_id/uid): %v (%q)", err, v) - } - if at, _ := cred["access_token"].(string); !strings.HasPrefix(at, "ACCESS-") { - t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", at) - } - // uid 来自 userInfo.UserID —— 签到设备身份的派生种子,丢了就无法签到 - if cred["uid"] != "u1" { - t.Fatalf("凭据应保留 uid=u1(签到设备身份的种子), 实得 %v", cred["uid"]) - } - if cred["enterprise_id"] != "t1" { - t.Fatalf("凭据应保留 TenantID, 实得 %v", cred["enterprise_id"]) - } - // 设备身份来自登录会话(授权 URL 里下发的那对 hex32) - if d, _ := cred["device_id"].(string); len(d) != 32 { - t.Fatalf("凭据应带 32 位 device_id(签到轮换用), 实得 %q", d) - } - }) - - t.Run("行2 无refreshToken_userJwtToken兜底_不打ExchangeToken", func(t *testing.T) { - before := atomic.LoadInt32(&h.exchHit) - st, body := h.beginAndCallback(map[string]string{ - "userJwt": `{"Token":"JWT-TOKEN-1","RefreshToken":""}`, - }) - if st != 200 { - t.Fatalf("应 200: %d %s", st, body) - } - v, _, _ := h.ps.GetSecret("channels", "channels:trae") - var cred2 map[string]interface{} - if err := json.Unmarshal([]byte(v), &cred2); err != nil { - t.Fatalf("凭据应为 JSON: %v (%q)", err, v) - } - if cred2["access_token"] != "JWT-TOKEN-1" { - t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %v", cred2["access_token"]) - } - if atomic.LoadInt32(&h.exchHit) != before { - t.Fatal("无 refreshToken 不应调 ExchangeToken(参考实现分支2)") - } - }) - - t.Run("行3 只有code_PKCE流_明确报暂不支持", func(t *testing.T) { - st, body := h.beginAndCallback(map[string]string{"code": "PKCE-CODE-1"}) - if st != 400 { - t.Fatalf("PKCE 流应 400: %d %s", st, body) - } - if !strings.Contains(body, "PKCE") { - t.Fatalf("错误应明确指向 PKCE 流, 实得 %s", body) - } - }) - - t.Run("行4 空回调_报缺失", func(t *testing.T) { - st, body := h.beginAndCallback(map[string]string{}) - if st != 400 { - t.Fatalf("空回调应 400: %d %s", st, body) - } - if !strings.Contains(body, "refreshToken") { - t.Fatalf("错误应指出缺什么, 实得 %s", body) - } - }) - - t.Run("行5 ExchangeToken失败_透传阶段", func(t *testing.T) { - atomic.StoreInt32(&h.exchOK, 0) - defer atomic.StoreInt32(&h.exchOK, 1) - st, body := h.beginAndCallback(map[string]string{"refreshToken": "RT-FROM-CALLBACK"}) - if st != 400 { - t.Fatalf("exchange 失败应 400: %d %s", st, body) - } - if !strings.Contains(body, "ExchangeToken") { - t.Fatalf("错误应指明 ExchangeToken 阶段, 实得 %s", body) - } - }) -} - -// TestTraeCallbackPoolPath 回归:callback 流的**池路径**(UI 真实流程)。 -// -// 与 buddy poll_login 的池路径同一缺陷:complete_login 不带 account_id 时, -// 凭据落到与占位账号无关的 `channels:` 键,池账号永远 has_credential=false。 -// 此前只有 device-poll / state-poll 分支修了,callback 分支是漏网。 -func TestTraeCallbackPoolPath(t *testing.T) { - h := newTraeHarness(t) - atomic.StoreInt32(&h.exchOK, 1) - - // UI 流程:先建占位账号,再带 account_id 发起登录 - add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"trae"}`, - }) - if err != nil { - t.Fatal(err) - } - aid := add.JSON.(map[string]interface{})["account_id"].(string) - - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: fmt.Sprintf(`{"platform":"trae","account_id":%q}`, aid), - }) - if err != nil { - t.Fatal(err) - } - nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string) - - st, body := h.invokeCallback(map[string]string{ - "state": nonce, "refreshToken": "RT-FROM-CALLBACK", "userInfo": `{"UserID":"u9","ScreenName":"池用户"}`, - }) - if st != 200 { - t.Fatalf("回调应 200: %d %s", st, body) - } - - // ① 凭据必须落在池账号的 ref - v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid) - if !ok || v == "" { - t.Fatalf("callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid) - } - // ② account_list 必须报 has_credential=true - lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"trae"}`, - }) - if err != nil { - t.Fatal(err) - } - for _, raw := range lst.JSON.(map[string]interface{})["accounts"].([]interface{}) { - a := raw.(map[string]interface{}) - if a["id"] == aid && a["has_credential"] != true { - t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"]) - } - } -} - -// invokeCallback 直打 oauth_callback(Query 已含 state)。 -func (h *traeHarness) invokeCallback(q map[string]string) (int, string) { - res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{ - Method: "GET", Query: q, - }) - if err != nil { - h.t.Fatal(err) - } - return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON) -} diff --git a/internal/core/channels_ttl_cache_test.go b/internal/core/channels_ttl_cache_test.go deleted file mode 100644 index ac733ad..0000000 --- a/internal/core/channels_ttl_cache_test.go +++ /dev/null @@ -1,100 +0,0 @@ -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_upstream_gaps_test.go b/internal/core/channels_upstream_gaps_test.go deleted file mode 100644 index eb869da..0000000 --- a/internal/core/channels_upstream_gaps_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// ★ 本文件把「上游客观缺口」写成**可执行的判据**,而不是只留在文档里。 -// -// 为什么需要:这些能力缺口多次被误判成"我们漏实现了"(我自己就误判了两轮), -// 于是反复派人去"补"。判据在此把事实钉住: -// -// - qoder 加密端点(agent_chat_generation)需要 `qoder-auth-wasm.wasm` -// —— 该二进制**在参考仓库里也不存在**(参考自己也是 readFileSync 一个 -// 它不提供的文件)。故我们走明文端点 `/model/v1/chat/completions`, -// **不经过信封** ⇒ qoder-envelope 的解包能力在明文路径上无从生效。 -// - TRAE 的 PKCE 分支参考**明确不支持**(只有"探测并拒绝",无 code→token -// 交换端点)⇒ 我们报"暂不支持"是与参考对齐的正确行为。 -// -// 判据的作用是**防止误判**:任何人(包括未来的我)想"补"这些能力时, -// 必须先让这个测试失败 —— 也就是先推翻事实,再动手。 -func TestUpstreamGapsAreDocumented(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - s := string(src) - - t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) { - // 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH。 - // ⚠ 断言 **URL 的两半**而不是那一整串字面量:本仓已把"路径从 base_url - // 移到 endpoint"(内核 URL = base_url + endpoint)—— 继续钉死整串会让 - // 一个纯粹的形态重构把这条**语义**判据弄红,那是判据在咬自己。 - if !strings.Contains(s, `base_url = "https://api2-v2.qoder.sh"`) || - !strings.Contains(s, `endpoint = "/model/v1/chat/completions"`) { - t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," + - "则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," + - "否则响应会被当成畸形数据") - } - // 明文端点没有信封层,故解包**不该**存在(存在反而是死代码) - if strings.Contains(s, "unwrapQoderEnvelope") { - t.Fatal("出现了 qoder 信封解包 —— 明文端点没有信封层。" + - "要么端点已换成加密端点(那需要 wasm),要么这是死代码") - } - }) - - t.Run("TRAE PKCE 明确报不支持(与参考一致)", func(t *testing.T) { - if !strings.Contains(s, "本实现暂不支持该流程") { - t.Fatal("TRAE 的 PKCE 分支必须明确报出「暂不支持」—— " + - "参考 trae-oauth.ts 也只做探测+拒绝,没有 code→token 交换端点。" + - "若改为尝试交换,会拿 code 去打要 RefreshToken 的 ExchangeToken(必然 400)") - } - }) - - t.Run("codearts 目录打两个签名端点(本轮补上)", func(t *testing.T) { - for _, want := range []string{ - "opengw.developer.huaweicloud.com/api/v1/gateway/config", - "snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin", - `sign = "codearts"`, - } { - if !strings.Contains(s, want) { - t.Fatalf("codearts 目录缺 %q —— 参考 fetchCodeArtsRemoteModels 打两个"+ - "**必须 AK/SK 签名**的端点,只打一个会少一半模型", want) - } - } - }) -} diff --git a/internal/core/channels_utf8clip_test.go b/internal/core/channels_utf8clip_test.go deleted file mode 100644 index 7653be8..0000000 --- a/internal/core/channels_utf8clip_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package core - -import ( - "testing" - "unicode/utf8" -) - -// 行为验证(不只是静态断言):utf8Clip 对中文截断后必须是合法 UTF-8。 -func TestUtf8ClipBehaviour(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - - s := "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,不为此放宽)—— 能力增强不可用,已退回兜底表" - for _, n := range []int{10, 40, 119, 120, 121, 400} { - out, err := ps.Call("channels", "__utf8_clip_probe", map[string]interface{}{"s": s, "n": n}) - if err != nil { - t.Fatal(err) - } - got, _ := jsonOf(t, out)["out"].(string) - if !utf8.ValidString(got) { - t.Errorf("n=%d 结果不是合法 UTF-8: %q", n, got) - } - if n < len(s) && len(got) > n { - t.Errorf("n=%d 结果超出字节上限: %d", n, len(got)) - } - if n >= len(s) && got != s { - t.Errorf("n=%d 未超长时应原样返回", n) - } - t.Logf("n=%-4d → %q (valid=%v, bytes=%d)", n, got, utf8.ValidString(got), len(got)) - } -} diff --git a/internal/core/channels_vnc_gpu_test.go b/internal/core/channels_vnc_gpu_test.go deleted file mode 100644 index 84e05af..0000000 --- a/internal/core/channels_vnc_gpu_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// VNC 一直白屏的真因(2026-10-14 定位)。 -// -// 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium -// **独立 GPU 进程**启动所需的系统调用 ⇒ 它起不来 ⇒ chromium 放弃并退出: -// -// ERROR:gpu_process_host.cc] GPU process launch failed: error_code=1002 -// FATAL:gpu_data_manager_impl_private.cc] GPU process isn't usable. Goodbye. -// -// 整个 chromium 几秒内退出 ⇒ **VNC 上永远白屏**,而插件因为只等 CDP 端口就报 -// 「已就绪」,把白屏谎报成成功。 -// -// 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功。定位靠 -// `systemd-run` 逐 directive 二分 —— 只有 SystemCallFilter 与 ProtectSystem -// 能复现,其余加固项无影响。 -// -// 修法:`--in-process-gpu`(GPU 逻辑跑在浏览器进程内,不 fork)。 -func TestBrowserStackUsesInProcessGPU(t *testing.T) { - b, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - // ① 必须带 --in-process-gpu,否则在加固环境下浏览器起不来 - if !strings.Contains(b, "--in-process-gpu") { - t.Fatal("chromium 命令行必须带 --in-process-gpu —— 否则在 " + - "SystemCallFilter=@system-service 的加固环境下 GPU 进程起不来、" + - "浏览器自杀、VNC 永远白屏(真实事故,2026-10-14)") - } - // ② 必须给可写的 HOME/XDG:ProtectHome=yes 下 /root 只读, - // chromium 建 dconf/ibus 目录会持续刷 permission denied 噪声 - for _, k := range []string{"XDG_CACHE_HOME=", "XDG_CONFIG_HOME="} { - if !strings.Contains(b, k) { - t.Errorf("chromium 命令行应设置 %s(加固环境下 /root 只读会产生噪声日志)", k) - } - } - // ③ **存活探针**必须存在:端口在听 ≠ 浏览器可用。 - // 没有它时,"浏览器已死"会被谎报成"已就绪",运维打开 VNC 是白屏, - // 还会以为是自己操作的问题。谎报比直接报错更坏。 - if !strings.Contains(b, "browserUtil.cdpAlive") { - t.Error("起栈后必须做 CDP 存活探针 —— 只等端口就报就绪会把白屏谎报成成功") - } - if !strings.Contains(b, "browserUtil.readTail") { - t.Error("启动失败必须把日志真因带给用户,而不是一句「启动失败」") - } - // ④ 失败必须回收整组进程(否则每次失败都留一套 Xvfb/x11vnc/websockify) - idx := strings.Index(b, "浏览器启动后即退出") - if idx < 0 { - t.Fatal("应有「浏览器启动后即退出」的失败分支") - } - if !strings.Contains(b[idx:idx+600], "reapBrowser()") { - t.Error("起栈失败必须先回收已起的进程(否则泄漏一套 Xvfb/x11vnc/websockify)") - } -} diff --git a/internal/core/channels_vnc_lifecycle_test.go b/internal/core/channels_vnc_lifecycle_test.go deleted file mode 100644 index 82dcd5c..0000000 --- a/internal/core/channels_vnc_lifecycle_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// VNC 会话生命周期的**前向引用**判据(真实 bug 的守卫)。 -// -// `sweepSessions` 里调用了 reapLoopback / browserStack / reapBrowser,而这三个 -// 助手定义在**更后面**。Lua 的 `local` 只从定义处向后可见 ⇒ 未前向声明时它们是 -// **全局 nil**:加载期不报错,只有真正走到"会话过期回收"那条分支才炸: -// -// attempt to call global 'reapLoopback' (a nil value) -// -// 症状极具迷惑性:**第一次登录完全正常**(没有过期会话可回收),第二次才 500, -// 且已经起来的浏览器栈**不会被回收**(reapBrowser 同样调不到)⇒ 进程与端口泄漏。 -// 本文件此前已因同一个坑踩过 12 次,故这里钉住"三者都必须有前向声明"。 -func TestSweepSessionsHelpersAreForwardDeclared(t *testing.T) { - b, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - // ① 必须存在对这三个助手的前向声明 - if !strings.Contains(b, "local reapLoopback, browserStack, reapBrowser") { - t.Fatal("sweepSessions 用到的三个助手必须**前向声明** —— 否则 " + - "`local` 只从定义处向后可见,运行到该分支时是 nil 调用(第一次登录" + - "正常、第二次 500,且浏览器栈不会被回收)") - } - // ② 定义处必须是**赋值**形态(绑定到那个 local),不能又是 `local function` - // —— 那会遮蔽前向声明,效果与没声明完全相同。 - for _, fn := range []string{"browserStack", "reapBrowser", "reapLoopback"} { - if strings.Contains(b, "local function "+fn+"(") { - t.Errorf("%s 的定必须是 `%s = function(...)` 形式(绑定前向声明);"+ - "再用 `local function` 会另起一个 local,前向声明形同虚设", fn, fn) - } - if !strings.Contains(b, "\n"+fn+" = function(") { - t.Errorf("找不到 %s 的赋值式定义", fn) - } - } - // ③ 声明必须**先于**使用(行号比较):这是前向引用的核心 - decl := strings.Index(b, "local reapLoopback, browserStack, reapBrowser") - use := strings.Index(b, "\n reapLoopback(id)\n") - if decl < 0 || use < 0 { - t.Fatalf("找不到声明(%d)或使用(%d)", decl, use) - } - if decl > use { - t.Error("前向声明必须在**使用之前** —— 否则 nil 调用") - } -} - -// 浏览器栈的**启动后存活**必须有真实判据(谎报"就绪"比直接报错更坏)。 -func TestBrowserStackVerifiesLivenessNotJustPort(t *testing.T) { - b, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - // CDP 真应答(端口在听 ≠ 服务可用) - if !strings.Contains(b, "browserUtil.cdpAlive") { - t.Error("必须用 CDP 真应答判活,不能只看端口") - } - // 进程真存活 - if !strings.Contains(b, "/proc/") { - t.Error("必须检查 chromium 进程仍存活(/proc/)") - } - // 失败要把日志真因带出来(而不是一句"启动失败") - if !strings.Contains(b, "browserUtil.readTail") { - t.Error("启动失败必须带出日志真因") - } -} diff --git a/internal/core/channels_vnc_test.go b/internal/core/channels_vnc_test.go deleted file mode 100644 index aec1ca4..0000000 --- a/internal/core/channels_vnc_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package core - -import ( - "net/url" - "strings" - "testing" - - "modelrouter/internal/lua" -) - -// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通: -// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口) -// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。 -// -// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。 -// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。 -func TestChannelsVncStackEndToEnd(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) - - // 依赖预检:缺件则跳过(并让日志说明) - depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"}) - if err != nil { - t.Fatalf("deps_status: %v", err) - } - if depOut.JSON.(map[string]interface{})["ok"] != true { - t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"]) - } - - // 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面 - injectTestPlatforms(t, ps, `[{ - "id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true, - "login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb", - "base_url": "https://api.vncfix.example/v1", "adapter": "openai", - "models": ["v-model-1"] - }]`) - - out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin", - }) - if err != nil { - t.Fatalf("start_login: %v", err) - } - m := out.JSON.(map[string]interface{}) - if e, _ := m["browser_error"].(string); e != "" { - t.Fatalf("浏览器栈未能启动: %s", e) - } - view, _ := m["view_url"].(string) - if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") { - t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view) - } - sess := m["session"].(map[string]interface{}) - if sess["cdp_port"] == nil { - t.Fatal("会话应带 CDP 端口(取凭据用)") - } - - // ★ noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()), - // 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。 - // 真实缺陷:传了站点根路径 ⇒ 路径被重复拼接,WebSocket 永远连不上, - // 页面看似白屏: - // .../proxy//api/plugins/http/channels/browser/proxy//websockify - // 本判据必须解析**产品自己给出的 URL**:我曾在验证时手拼 URL 并省掉 - // path 参数(默认值恰好正确)⇒ 否定了真实缺陷。 - u, perr := url.Parse(view) - if perr != nil { - t.Fatalf("view_url 不可解析: %v", perr) - } - q := u.Query() - wspath := q.Get("path") - if wspath == "" { - t.Fatal("view_url 必须显式声明 path=websockify:依赖 noVNC 隐式默认值" + - "会在 URL 形状变化时静默破裂") - } - if strings.HasPrefix(wspath, "/") { - t.Fatalf("path 必须是相对名(noVNC 相对 vnc.html 目录解析),"+ - "以 / 开头会被当相对路径而重复拼接,实得 %q", wspath) - } - resolved := u.ResolveReference(&url.URL{Path: wspath}) - pages := strings.TrimSuffix(u.Path, "/vnc.html") - if resolved.Path != pages+"/websockify" { - t.Fatalf("path 解析后应为 %q(同级 websockify),实得 %q —— 重复拼接即白屏根因", - pages+"/websockify", resolved.Path) - } - - t.Logf("VNC 栈已起: %s → WebSocket %s (CDP %v)", view, resolved.Path, sess["cdp_port"]) - - // 收尾:回收浏览器栈(测试不留孤儿进程) - if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{ - Method: "POST", Role: "admin", - }); err != nil { - t.Fatalf("stop_browser: %v", err) - } -} - -// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。 -// -// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids; -// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存 -// (实测一次跑测试留下 4 个 orphan Xvfb)。 -// -// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的 -// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。 -func TestChannelsVncNoOrphanOnFailure(t *testing.T) { - c := kernelTestCore(t) - ps := c.Plugins() - src, err := lua.ReadBundledPlugin("channels") - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) - - depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"}) - if depOut.JSON.(map[string]interface{})["ok"] != true { - t.Skip("跳过:缺浏览器栈依赖") - } - // 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行; - // 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 —— - // 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。 - injectTestPlatforms(t, ps, `[{ - "id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true, - "login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb", - "base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"] - }]`) - out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin", - }) - if err != nil { - t.Fatal(err) - } - if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" { - t.Skipf("本机浏览器栈起不来(%s),跳过", e) - } - // 起来后立刻回收 - if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil { - t.Fatal(err) - } - // 回收后状态必须清零 - st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"}) - if st.JSON.(map[string]interface{})["running"] != false { - t.Fatal("stop_browser 后 running 应为 false") - } -} diff --git a/internal/core/channels_xdisplay_test.go b/internal/core/channels_xdisplay_test.go deleted file mode 100644 index 6be4427..0000000 --- a/internal/core/channels_xdisplay_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package core - -import ( - "os" - "strconv" - "strings" - "testing" - - "modelrouter/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 占满后每次必失败。 - // - // ⚠️ 号段必须**跟着测试台的 XDISPLAY_BASE 走**(不是硬编码 91/92/93)。 - // 原因:生产 unit 带 PrivateTmp,它起的 Xvfb :91 的锁在服务自己的 /tmp 里, - // 宿主测试看不到 ⇒ 测试会去抢 91 并失败。测试台因此把起点挪到 300 - // (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的 - // 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。 - base := 300 - if v := os.Getenv("MODELROUTER_XDISPLAY_BASE"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - base = n - } - } - ghosts := []int{base, base + 1, base + 2} - for _, n := range ghosts { - l := "/tmp/.X" + strconv.Itoa(n) + "-lock" - if _, err := os.Stat(l); err == nil { - t.Skipf("前置条件不满足:%s 已存在(先清理测试环境再跑)", l) - } - if err := os.WriteFile(l, []byte(ghost+"\n"), 0o644); err != nil { - t.Skipf("无法在 /tmp 造锁(跳过): %v", err) - } - defer os.Remove(l) - } - lock := "/tmp/.X" + strconv.Itoa(base) + "-lock" - sock := "/tmp/.X11-unix/X" + strconv.Itoa(base) - - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - // 12 个真实平台里已无 vnc 流,故注入一个夹具(夹具不写进生产目录)。 - injectTestPlatforms(t, ps, `[{ - "id": "xdispfx", "name": "显示器夹具", "flow": "vnc", "needs_browser": true, - "login_url": "http://127.0.0.1:18099/login", - "base_url": "https://api.xdispfx.example/v1", "adapter": "openai", - "models": ["x-model-1"] - }]`) - - out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"xdispfx"}`, Role: "admin", - }) - if err != nil { - t.Fatalf("start_login: %v", err) - } - j, _ := out.JSON.(map[string]interface{}) - if j == nil { - t.Fatalf("无 JSON 响应 (status=%d)", out.Status) - } - msg, _ := j["browser_error"].(string) - if strings.Contains(msg, "already active for display") { - t.Fatalf("陈旧锁把选号挡死了(应回收):%s", msg) - } - if msg != "" { - // 缺浏览器依赖起不来是允许的 —— 但陈旧锁仍必须被回收(不占号)。 - if _, err := os.Stat(lock); err == nil { - t.Errorf("陈旧锁未被回收: %s(记录 PID %s 已不存在)", lock, ghost) - } - t.Skipf("浏览器栈未启动(与选号无关): %s", msg) - } - - // ② 插件确实占用了某个显示器,且锁里是**活着的** PID(不是那个幽灵)。 - st, err := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{ - Method: "GET", Role: "admin", - }) - if err != nil { - t.Fatalf("browser_status: %v", err) - } - sess, _ := st.JSON.(map[string]interface{})["session"].(map[string]interface{}) - used := 0 - if sess != nil { - if f, ok := sess["display"].(float64); ok { - used = int(f) - } - } - if used == 0 { - t.Fatalf("会话未报告 display: %v", st.JSON) - } - // 关键断言:选中的必须是那 3 个陈旧号之一 —— 证明陈旧锁被**回收复用**了。 - // 只跳过不复用的实现(变异)会选 94,从而被抓到。 - reclaimed := false - for _, n := range ghosts { - if used == n { - reclaimed = true - } - } - if !reclaimed { - t.Errorf("选中的 :%d 不是陈旧号 %v —— 陈旧锁没被回收复用(号段会被逐个烧掉,最终饿死)", used, ghosts) - } - if raw, err := os.ReadFile("/tmp/.X" + strconv.Itoa(used) + "-lock"); err == nil { - pid := strings.TrimSpace(string(raw)) - if pid == ghost { - t.Errorf("选中的显示器 :%d 锁里仍是幽灵 PID(陈旧锁没被换掉)", used) - } else if pid != "" { - if _, err := os.Stat("/proc/" + pid); err != nil { - t.Errorf("选中的显示器 :%d 锁里 PID %s 不存在(记录与事实不符)", used, pid) - } - } - } else { - t.Logf(":%d 未生成锁文件(部分 Xvfb 行为,可接受)", used) - } - - // ③ 收栈后必须不留锁/socket —— 这条直接钉住「用 kill -9 收尾却不清理」的泄漏。 - if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{ - Method: "POST", Role: "admin", - }); err != nil { - t.Fatalf("stop_browser: %v", err) - } - dl := "/tmp/.X" + strconv.Itoa(used) + "-lock" - ds := "/tmp/.X11-unix/X" + strconv.Itoa(used) - if _, err := os.Stat(dl); err == nil { - t.Errorf("收栈后锁未清理: %s(下次会把这个号误判为被占 → 号被永久烧掉)", dl) - _ = os.Remove(dl) - } - if _, err := os.Stat(ds); err == nil { - t.Errorf("收栈后 socket 未清理: %s", ds) - _ = os.Remove(ds) - } - _ = sock -} - -// 反向判据:**活着的**占用不得被回收(否则会把别人正在用的显示器抢走)。 -func TestXDisplaySelectionKeepsLiveLocks(t *testing.T) { - const disp = 92 - lock := "/tmp/.X" + strconv.Itoa(disp) + "-lock" - if _, err := os.Stat(lock); err == nil { - t.Skipf("前置条件不满足:%s 已存在", lock) - } - // 用一个**真实存活**的 PID(本进程)写锁。 - if err := os.WriteFile(lock, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil { - t.Skipf("无法在 /tmp 造锁(跳过): %v", err) - } - defer os.Remove(lock) - - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - injectTestPlatforms(t, ps, `[{ - "id": "xdispfx2", "name": "显示器夹具2", "flow": "vnc", "needs_browser": true, - "login_url": "http://127.0.0.1:18097/login", - "base_url": "https://api.xdispfx2.example/v1", "adapter": "openai", - "models": ["x-model-2"] - }]`) - defer func() { - _, _ = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{ - Method: "POST", Role: "admin", - }) - }() - - if _, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"xdispfx2"}`, Role: "admin", - }); err != nil { - t.Fatalf("start_login: %v", err) - } - - // 记录的是活 PID → 该锁必须原样保留(不得抢占别人正在用的显示器)。 - raw, err := os.ReadFile(lock) - if err != nil { - t.Fatalf("活锁被误回收: %s(记录的是真实存活 PID,不得回收)", lock) - } - if got := strings.TrimSpace(string(raw)); got != strconv.Itoa(os.Getpid()) { - t.Errorf("活锁内容被改动: %q(应保持原样)", got) - } -} diff --git a/internal/core/channels_zcode_3012_test.go b/internal/core/channels_zcode_3012_test.go deleted file mode 100644 index 6d69957..0000000 --- a/internal/core/channels_zcode_3012_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package core - -import ( - "encoding/json" - "strings" - "testing" -) - -// ★ ZCode 3012 的判据必须是**多形态**的,且误判不是"文案不好看"—— -// 领取路径会据此**短路**(跳过本可领取的 plan),而限流与风控的用户动作 -// **完全相反**(稍后重试 vs 别重试)。 -// -// 本判据逐行搬自参考 zcode-diagnostics.ts 的对抗性审计实测矩阵 -// (2026-10-05 自审 + 审计打出的真实缺陷)。 -// -// 为什么用**共用模块**验:这段逻辑被领取路径(插件主体)与推理路径 -// (channels-zcode 适配器)共用,任一侧漂移都会让"请勿连续重试"只出现在 -// 一条路径上。判据直接打共用模块的导出,两边用的是同一份源码文本。 -func TestZcode3012ClassificationMatrix(t *testing.T) { - h := newZcodeIdentityHarness(t) - - cases := []struct { - name string - body string - want bool - }{ - // ── 真风控(必须命中)── - {"上游真实文案(带码)", `{"code":3012,"msg":"request has been blocked due to unusual activity."}`, true}, - {"码是纯数字字符串", `{"code":"3012","msg":"x"}`, true}, - {"仅正文语义(无数字)", `request has been blocked due to unusual activity.`, true}, - {"3012 与 block 紧邻", `3012 blocked by waf`, true}, - - // ── 必须**不**命中(误判会让用户做错事)── - {"13012 不是 3012", `{"code":13012}`, false}, - {"标识符 plan-3012-trust", `plan-3012-trust`, false}, - {"标识符 trust_3012", `trust_3012`, false}, - {"字母包裹 x3012y", `x3012y`, false}, - {"限流的毫秒数", `too many requests, retry after 3012 ms`, false}, - {"HTML 里的 3012", `

Error 3012

upstream connect timeout`, false}, - {"限流+block 距 14", `rate limited: request blocked, retry after 3012 ms`, false}, - {"block 距 12", `temporarily blocked; see error 3012`, false}, - {"额度码 1005", `{"code":1005,"msg":"quota"}`, false}, - - // ── 隔离「词边界」规则:这些用例同时满足"block 紧邻", - // 只有词边界能区分 ⇒ 裸子串实现会把前三个判成风控。 - // (最初的矩阵没覆盖这一组,变异测试因此没咬住 —— 补上后才有效。) - {"标识符+block 紧邻", `plan-3012 blocked`, false}, - {"字母包裹+block 紧邻", `x3012 blocked`, false}, - {"下划线+block 紧邻", `trust_3012 blocked`, false}, - {"独立词+block 紧邻", `3012 blocked`, true}, - {"13012 里的片段+block 紧邻", `13012 blocked`, false}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := h.callZD("hasUnusualActivity", tc.body) - if got != tc.want { - t.Errorf("hasUnusualActivity(%q) = %v, want %v\n"+ - "(误判的后果不是文案问题:领取路径会据此短路,且限流='稍后重试'、\n"+ - " 风控='别重试',两者用户动作完全相反)", tc.body, got, tc.want) - } - }) - } -} - -// ★ 时间差格式化必须逐档单独算:参考初版把分钟档写成 `floor(seconds % 60)`, -// 于是 90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)。 -// 该错误是在**真实探针输出**里发现的,纯函数单测当时没覆盖 ≥60 秒的区间。 -func TestZcodeFormatGapDoesNotMisrenderMinutes(t *testing.T) { - h := newZcodeIdentityHarness(t) - for _, tc := range []struct { - ms int - want string - }{ - {90000, "1分30秒"}, // 参考踩过的坑:错法会得到 "30分30秒" - {59000, "59.0s"}, // 不足一分钟 - {60000, "1分"}, // 整分钟 - {3600000, "1小时"}, // 整小时 - {3900000, "1小时5分"}, // 小时+分 - {500, "500ms"}, - } { - got := h.callZDString("formatGap", tc.ms) - if got != tc.want { - t.Errorf("formatGap(%d) = %q, want %q", tc.ms, got, tc.want) - } - } -} - -// ★ 诊断行**绝不能含凭据片段**(参考的设计红线之一)。 -// -// 账号只以"进程内自增序号"出现。判据用「传入的账号 id 的每个子串都不得出现在 -// 文案里」来钉死这条 —— 不是"看起来没有"。 -func TestZcodeDiagnosticLeaksNoCredentials(t *testing.T) { - h := newZcodeIdentityHarness(t) - const secretID = "zcode-abcdef1234567890-SECRET" - h.callZDAction("accountOrdinal", secretID) - h.callZDAction2("noteRequestSent", secretID, 1000) - h.callZDAction2("noteRequestOk", secretID, 1100) - - diag := h.callZDString("formatDiagnostic", map[string]interface{}{ - "accountId": secretID, - "sentAt": 1200, - "now": 1300, - "status": 405, - }) - if diag == "" { - t.Fatal("formatDiagnostic 返回空") - } - // 账号 id 的每个 >=4 字符子串都不得出现 - for i := 0; i+4 <= len(secretID); i++ { - if strings.Contains(diag, secretID[i:i+4]) { - t.Fatalf("诊断行泄漏账号 id 片段 %q:%s", secretID[i:i+4], diag) - } - } - if !strings.Contains(diag, "账号#") { - t.Fatalf("诊断行应含「账号#序号」形态:%s", diag) - } - t.Logf("✓ 诊断行无凭据片段: %s", diag) -} - -// ★ 3012 的文案必须**共用**(推理 + 领取两条路径),且必须含"请勿连续重试"。 -// -// 3012 不是"重试就能过"(那是 3007),而是**有账号冷却惩罚**: -// 30 分钟起步,反复触发升级到 24 小时乃至停用。 -func TestZcode3012MessageIsSharedAndWarnsAboutRetries(t *testing.T) { - h := newZcodeIdentityHarness(t) - - msg := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{ - "subject": "领取 plan-x", - "rawResponse": "request has been blocked", - }) - for _, want := range []string{"3012", "请勿连续重试", "冷却惩罚", "领取 plan-x"} { - if !strings.Contains(msg, want) { - t.Fatalf("风控文案缺 %q:%s", want, msg) - } - } - - // 空 subject/diagnostic/rawResponse 时不得留下空壳("本机诊断:"后面啥都没有) - bare := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{}) - if strings.Contains(bare, "本机诊断:\n") || strings.Contains(bare, "原始响应:\n") { - t.Fatalf("空参数不应留下空壳段:%q", bare) - } - t.Logf("✓ 文案: %s", strings.SplitN(msg, "\n", 2)[0]) -} - -// ★ 请求体形态诊断必须读**真正要发出去的对象**,而不是常量。 -// -// 常量只能证明"源码里写了多少字",证明不了"线上发出去多少字"。 -// 两者一旦不一致(例如某条改动把 system 顶掉了),只有读实际值才看得出来 -// —— 它会**如实显示 0 字符**,而不是拿常量里的 2898 把问题盖住。 -func TestZcodeRequestShapeReadsActualObject(t *testing.T) { - h := newZcodeIdentityHarness(t) - - // 用真实的适配器产出 → 再诊断它(而不是手搓一个假 system) - out, err := h.transformRequest(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatal(err) - } - var req map[string]interface{} - if err := jsonUnmarshal(out, &req); err != nil { - t.Fatal(err) - } - shape := h.callZDMap("describeRequestShape", req["system"], req["messages"]) - if shape == nil { - t.Fatal("describeRequestShape 返回空") - } - chars, _ := shape["identityChars"].(float64) - if chars < 2800 { - t.Fatalf("身份块实测字符数 %v < 2800(诊断必须读实际值)", chars) - } - if shape["hasDateBlock"] != true { - t.Fatalf("应检测到日期块,实得 %v", shape["hasDateBlock"]) - } - if shape["firstRole"] != "user" { - t.Fatalf("firstRole 应为 user,实得 %v", shape["firstRole"]) - } - t.Logf("✓ 形态诊断: 块数=%v 身份块=%v 日期块=%v role=%v", - shape["systemBlocks"], chars, shape["hasDateBlock"], shape["firstRole"]) - - // 反例:空 system ⇒ 必须**如实显示 0**(不能被常量盖住) - empty := h.callZDMap("describeRequestShape", []interface{}{}, req["messages"]) - if c, _ := empty["identityChars"].(float64); c != 0 { - t.Fatalf("空 system 时 identityChars 必须为 0(如实反映实际值),实得 %v", c) - } -} - -// ── harness 助手:经共用的探针打**那一份** ZD 实现 ── -// -// 走探针而不是让测试自己 load 一份副本:判据必须打真实被两条路径共用的实现, -// 自己 load 副本会退化成"各自测各自的",共用一旦漂移就测不出来。 - -func (h *zcodeIdentityHarness) callZD(fn string, a interface{}) bool { - h.t.Helper() - res, err := h.ps.Call("channels", "__zcode_diag_probe", - map[string]interface{}{"fn": fn, "a": a}) - if err != nil { - h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err) - } - m := res.(map[string]interface{})["json"].(map[string]interface{}) - if e, ok := m["error"]; ok { - h.t.Fatalf("探针错误(%s): %v", fn, e) - } - v, ok := m["result"].(bool) - if !ok { - h.t.Fatalf("%s 返回非布尔: %#v", fn, m["result"]) - } - return v -} - -func (h *zcodeIdentityHarness) callZDString(fn string, a interface{}) string { - h.t.Helper() - var res interface{} - var err error - if opts, ok := a.(map[string]interface{}); ok { - res, err = h.ps.Call("channels", "__zcode_diag_probe", - map[string]interface{}{"fn": fn, "opts": opts}) - } else if fn == "formatGap" { - res, err = h.ps.Call("channels", "__zcode_diag_probe", - map[string]interface{}{"fn": fn, "a": a}) - } else { - res, err = h.ps.Call("channels", "__zcode_diag_probe", - map[string]interface{}{"fn": fn, "a": a}) - } - if err != nil { - h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err) - } - m := res.(map[string]interface{})["json"].(map[string]interface{}) - if e, ok := m["error"]; ok { - h.t.Fatalf("探针错误(%s): %v", fn, e) - } - s, _ := m["result"].(string) - return s -} - -// callZDAction / callZDAction2:调"动作型"探针(不返回结果,只看没报错)。 -func (h *zcodeIdentityHarness) callZDAction(fn string, a interface{}) { - h.t.Helper() - res, err := h.ps.Call("channels", "__zcode_diag_probe", - map[string]interface{}{"fn": fn, "a": a}) - if err != nil { - h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err) - } - if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil { - h.t.Fatalf("探针错误(%s): %v", fn, m["error"]) - } -} - -func (h *zcodeIdentityHarness) callZDAction2(fn string, a interface{}, a2 int) { - h.t.Helper() - res, err := h.ps.Call("channels", "__zcode_diag_probe", - map[string]interface{}{"fn": fn, "a": a, "a2": a2}) - if err != nil { - h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err) - } - if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil { - h.t.Fatalf("探针错误(%s): %v", fn, m["error"]) - } -} - -func (h *zcodeIdentityHarness) callZDMap(fn string, args ...interface{}) map[string]interface{} { - h.t.Helper() - var res interface{} - var err error - if len(args) == 2 { - res, err = h.ps.Call("channels", "__zcode_diag_probe", - map[string]interface{}{"fn": fn, "system": args[0], "messages": args[1]}) - } else { - res, err = h.ps.Call("channels", "__zcode_diag_probe", - map[string]interface{}{"fn": fn, "a": args[0]}) - } - if err != nil { - h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err) - } - m := res.(map[string]interface{})["json"].(map[string]interface{}) - if e, ok := m["error"]; ok { - h.t.Fatalf("探针错误(%s): %v", fn, e) - } - out, _ := m["result"].(map[string]interface{}) - return out -} - -// jsonUnmarshal 是本文件用的小助手(避免与其它测试文件的导入冲突)。 -func jsonUnmarshal(s string, v interface{}) error { - return json.Unmarshal([]byte(s), v) -} diff --git a/internal/core/channels_zcode_identity_test.go b/internal/core/channels_zcode_identity_test.go deleted file mode 100644 index b56a135..0000000 --- a/internal/core/channels_zcode_identity_test.go +++ /dev/null @@ -1,411 +0,0 @@ -package core - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "modelrouter/internal/lua" -) - -// ★ ZCode 的身份块是 3012 准入的**唯一开关**。 -// -// 参考实测(三个账号、17 次请求):system 缺官方身份块结构时,上游直接 -// -// HTTP 405 + {"code":3012,"msg":"...unusual activity."} -// -// 且与 HTTP 头、版本号、请求频率、多轮历史、tools 声明、运行时**全部无关**。 -// -// 判据矩阵(照参考,逐条锁定): -// -// 无 system → 3012 -// 仅 cliPrefix(42 字符) → 3012 -// cliPrefix + stable(约 2898 字符)→ 200 -// -// ⇒ 判据是「身份块**结构**是否存在」,不是字符数凑够。 -// -// 本判据看**适配器真正产出给上游的请求体**(transform_request 的返回值), -// 而不是"适配器装了没"这类内部信号。 -func TestZcodeAdapterInjectsOfficialIdentityBlocks(t *testing.T) { - h := newZcodeIdentityHarness(t) - - out, err := h.transformRequest(`{"model":"glm-5.3-flash","messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatalf("transform_request: %v", err) - } - var req map[string]interface{} - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatalf("产出不是 JSON: %v\n%s", err, out) - } - - // ① system 必须是**块数组**(官方形态),不是纯字符串 - sys, ok := req["system"].([]interface{}) - if !ok { - t.Fatalf("system 必须是块数组(上游检查的是这个结构),实得 %T: %v", - req["system"], req["system"]) - } - if len(sys) < 3 { - t.Fatalf("system 块数至少 3(cliPrefix + stable + Environment),实得 %d", len(sys)) - } - - // ② block[0] 必须是官方 CLI 前缀(准入必需) - b0, _ := sys[0].(map[string]interface{}) - if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" { - t.Fatalf("block[0] 必须是官方 CLI 前缀(逐字),实得 %q", got) - } - - // ③ block[1] 必须是官方 stable 段(准入必需),长度对得上 - b1, _ := sys[1].(map[string]interface{}) - stable, _ := b1["text"].(string) - if len(stable) < 2800 { - t.Fatalf("block[1](stable 段)长度 %d < 2800 —— 身份块不完整会被判为裸请求(3012)", - len(stable)) - } - for _, marker := range []string{ - "You are an interactive ZCode agent", - "# Harness", - "# ZCode Desktop Context", - } { - if !strings.Contains(stable, marker) { - t.Fatalf("stable 段缺 %q —— 身份块被截断/改写", marker) - } - } - - // ④ block[2] 是 Environment 段(官方每会话都发;不发则模型不知 cwd/平台) - b2, _ := sys[2].(map[string]interface{}) - env, _ := b2["text"].(string) - if !strings.HasPrefix(env, "# Environment") { - t.Fatalf("block[2] 必须是 Environment 段,实得 %q", env[:zcodeMin(40, len(env))]) - } - for _, marker := range []string{"Primary working directory", "Platform", "powered by the model named"} { - if !strings.Contains(env, marker) { - t.Fatalf("Environment 段缺 %q:%s", marker, env) - } - } - - // ⑤ 总长度必须达到参考声明的量级(2898) - total := len("You are ZCode, an interactive coding agent") + len(stable) - if total < 2800 { - t.Fatalf("身份块合计 %d 字符 < 2800(参考实测的准入值约 2898)", total) - } - - // ⑥ 日期块:插到第一条 user 消息的 content **数组**最前(官方形态) - msgs, _ := req["messages"].([]interface{}) - if len(msgs) == 0 { - t.Fatal("messages 为空") - } - m0, _ := msgs[0].(map[string]interface{}) - content, ok := m0["content"].([]interface{}) - if !ok { - t.Fatalf("首条 user 的 content 必须是**块数组**(拼进字符串会改变结构、仍被判裸请求),实得 %T", - m0["content"]) - } - c0, _ := content[0].(map[string]interface{}) - if got, _ := c0["text"].(string); !strings.Contains(got, "") { - t.Fatalf("content[0] 必须是日期块(),实得 %q", got) - } - - t.Logf("✓ 身份块注入正确:块数=%d stable=%d 合计=%d", len(sys), len(stable), total) -} - -// ★ 调用方的 system 必须**追加在最后**:上游检查看的是**前缀结构**, -// 官方身份块必须处在开头位置。若被前置会让身份块不在前缀而吃 3012。 -func TestZcodeCallerSystemIsAppendedLast(t *testing.T) { - h := newZcodeIdentityHarness(t) - out, err := h.transformRequest( - `{"model":"glm-5.3-flash","messages":[{"role":"system","content":"CALLER_PROMPT_XYZ"},{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatal(err) - } - var req map[string]interface{} - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatal(err) - } - sys, _ := req["system"].([]interface{}) - if len(sys) < 4 { - t.Fatalf("带调用方 system 时应为 4 块(3 官方 + 1 调用方),实得 %d", len(sys)) - } - last, _ := sys[len(sys)-1].(map[string]interface{}) - lt, _ := last["text"].(string) - if !strings.Contains(lt, "CALLER_PROMPT_XYZ") { - t.Fatalf("调用方 system 必须在**最后一块**(上游看前缀结构;前置会让身份块不在前缀)") - } - // 且前三块仍是官方块(身份块没有被调用方内容挤走) - b0, _ := sys[0].(map[string]interface{}) - if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" { - t.Fatalf("block[0] 必须仍是官方 CLI 前缀,实得 %q", got) - } -} - -// 适配器自检出口:身份块长度与 anthropic 基座装载状态。 -// 「装了但身份块是空的」这类问题(转义/截断/基座读不到)没有它只能靠猜。 -func TestZcodeAdapterProbeReportsIdentityLength(t *testing.T) { - h := newZcodeIdentityHarness(t) - res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{}) - if err != nil { - t.Fatal(err) - } - j := res.(map[string]interface{})["json"].(map[string]interface{}) - zc, ok := j["zcode"].(map[string]interface{}) - if !ok { - t.Fatalf("探针未报告 zcode 适配器装载结果: %v", j) - } - if zc["ok"] != true { - t.Fatalf("zcode 适配器装载失败: %v", zc["err"]) - } -} - -// zcodeIdentityHarness:加载插件(它会**自装** channels-zcode 适配器), -// 然后直接调那个适配器的 transform_request。 -// -// 直接调已装载的适配器(而不是自己去 load 源码)更接近真实路径:判据验的是 -// 「上游实际收到的请求体」,包含前导注入、桥的装载等全部环节。 -type zcodeIdentityHarness struct { - t *testing.T - c *Core - ps *lua.Plugins -} - -func newZcodeIdentityHarness(t *testing.T) *zcodeIdentityHarness { - t.Helper() - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - return &zcodeIdentityHarness{t: t, c: c, ps: ps} -} - -// transformRequest 调**已自装的** channels-zcode 适配器的 transform_request。 -func (h *zcodeIdentityHarness) transformRequest(body string) (string, error) { - h.t.Helper() - vm := h.c.VM() - if vm == nil { - h.t.Fatal("VM 不可用") - } - return vm.Transform("channels-zcode", "transform_request", body) -} - -func zcodeMin(a, b int) int { - if a < b { - return a - } - return b -} - -// ★ 身份块注入不得破坏协议转换:工具调用、图像、流式帧仍必须走 anthropic 那一份。 -// -// 为什么必须验:本适配器的手法是把 anthropic.lua 的源码读进来执行、只覆盖 -// transform_request。若「读不到基座」或「覆盖时丢了字段」,协议转换会静默退化 -// —— 表现为工具调用参数丢失、图像块消失,而身份块看起来完全正常。 -func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) { - h := newZcodeIdentityHarness(t) - - t.Run("工具定义与工具调用存活", func(t *testing.T) { - in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":"go"}],` + - `"tools":[{"type":"function","function":{"name":"get_weather",` + - `"description":"d","parameters":{"type":"object","properties":{"c":{"type":"string"}}}}}]}` - out, err := h.transformRequest(in) - if err != nil { - t.Fatal(err) - } - // anthropic 形态:tools[].name / input_schema(顶层,不是嵌套在 function 里) - if !strings.Contains(out, `"get_weather"`) { - t.Fatalf("工具定义在转换中丢失(identity 注入不该影响它):%s", out) - } - if !strings.Contains(out, `"input_schema"`) { - t.Fatalf("工具 schema 未转成 anthropic 形态(input_schema 缺失):%s", out) - } - }) - - t.Run("多模态图像块存活", func(t *testing.T) { - in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":[` + - `{"type":"text","text":"what"},` + - `{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}]}]}` - out, err := h.transformRequest(in) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, `"image"`) || !strings.Contains(out, `"base64"`) { - t.Fatalf("图像块在转换中丢失:%s", out) - } - }) - - t.Run("基座装载状态可见", func(t *testing.T) { - // 读适配器的自检出口:base_loaded 必须为 true(否则身份块虽在、 - // 协议转换已退化为透传) - res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{}) - if err != nil { - t.Fatal(err) - } - j := res.(map[string]interface{})["json"].(map[string]interface{}) - zc := j["zcode"].(map[string]interface{}) - if zc["ok"] != true { - t.Fatalf("zcode 适配器未装载: %v", zc) - } - }) -} - -// ★ 基座(anthropic.lua)读不到时必须在**安装期响亮失败**,不得静默退化。 -// -// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块 -// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型 -// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。 -// -// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有 -// -// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得 -// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。 -func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) { - // 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到 - dir := t.TempDir() - pluginDir := filepath.Join(dir, "plugins") - if err := os.MkdirAll(pluginDir, 0755); err != nil { - t.Fatal(err) - } - t.Setenv("MODELROUTER_PLUGIN_DIR", pluginDir) - - c := kernelTestCore(t) - ps := c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - // 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 anthropic - res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{ - "adapters_dir": filepath.Join(dir, "adapters-no-base"), - }) - if err != nil { - t.Fatal(err) - } - j := res.(map[string]interface{})["json"].(map[string]interface{}) - zc, ok := j["zcode_reinstall"].(map[string]interface{}) - if !ok { - t.Fatalf("安装探针应回报 zcode_reinstall 结果,实得 %v", j) - } - if zc["ok"] == true { - t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc) - } - msg, _ := zc["err"].(string) - if !strings.Contains(msg, "anthropic") { - t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg) - } - t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg) -} - -// ★ 日期块必须**幂等**:已以 开头则不重复插。 -// -// 不判的后果:同一请求重试/多次 transform 时日期块叠成多层,改变 content 的 -// 前缀结构(上游检查的正是结构)。参考 withContextPrefix 有 startsWithSystemReminder。 -func TestZcodeContextPrefixIsIdempotent(t *testing.T) { - h := newZcodeIdentityHarness(t) - // 已是插入后的形态(首条 user 的 content[0] 就是 system-reminder) - in := `{"model":"m","messages":[{"role":"user","content":[` + - `{"type":"text","text":"\nOLD\n"},` + - `{"type":"text","text":"hi"}]}]}` - out, err := h.transformRequest(in) - if err != nil { - t.Fatal(err) - } - var req map[string]interface{} - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatal(err) - } - msgs := req["messages"].([]interface{}) - content := msgs[0].(map[string]interface{})["content"].([]interface{}) - n := 0 - for _, c := range content { - if b, ok := c.(map[string]interface{}); ok { - if s, _ := b["text"].(string); strings.Contains(s, "") { - n++ - } - } - } - if n != 1 { - t.Fatalf("日期块必须幂等(已存在则不重复插),实得 %d 个 system-reminder 块", n) - } -} - -// ★ 日期块只处理**第一条**消息,且它必须 role == "user"。 -// -// 反例(我第一版就是这样):往后扫第一个 user 消息 —— 若首条是 assistant, -// 官方行为是**不动**,而不是去找后面那条 user。判据钉住这个差别。 -func TestZcodeContextPrefixOnlyTouchesFirstMessage(t *testing.T) { - h := newZcodeIdentityHarness(t) - - t.Run("首条不是 user 时不动", func(t *testing.T) { - in := `{"model":"m","messages":[` + - `{"role":"assistant","content":"opening"},` + - `{"role":"user","content":"hi"}]}` - out, err := h.transformRequest(in) - if err != nil { - t.Fatal(err) - } - var req map[string]interface{} - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatal(err) - } - msgs := req["messages"].([]interface{}) - // ⚠ 注意:文本会被 anthropic 基座转成**块数组**(协议转换的正常行为), - // 所以这里不能断言 content 仍是字符串 —— 我第一版就这么断言,把正确的 - // 实现判成了错。判据要验的是「**日期块**有没有被插进来」这个事实。 - noReminder := func(content interface{}) bool { - switch c := content.(type) { - case string: - return !strings.Contains(c, "") - case []interface{}: - for _, e := range c { - if b, ok := e.(map[string]interface{}); ok { - if t, _ := b["text"].(string); strings.Contains(t, "") { - return false - } - } - } - } - return true - } - // 首条(assistant)不该被插日期块 - if !noReminder(msgs[0].(map[string]interface{})["content"]) { - t.Fatal("首条是 assistant 时不该插日期块(官方只看第一条且要求 role=user)") - } - // 后面的 user 也**不该**被插(官方不往后找) - if !noReminder(msgs[1].(map[string]interface{})["content"]) { - t.Fatal("日期块被插到了后面的 user 消息上 —— 官方只看第一条") - } - // 且原文本内容必须保留(转换不能丢内容) - b, _ := json.Marshal(msgs[0]) - if !strings.Contains(string(b), "opening") { - t.Fatal("首条 assistant 的内容丢了") - } - }) - - t.Run("首条是 user 时插入", func(t *testing.T) { - in := `{"model":"m","messages":[{"role":"user","content":"hi"}]}` - out, err := h.transformRequest(in) - if err != nil { - t.Fatal(err) - } - var req map[string]interface{} - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatal(err) - } - msgs := req["messages"].([]interface{}) - content, ok := msgs[0].(map[string]interface{})["content"].([]interface{}) - if !ok { - t.Fatal("首条 user 的 content 应升级为块数组") - } - b0 := content[0].(map[string]interface{}) - if s, _ := b0["text"].(string); !strings.Contains(s, "") { - t.Fatalf("首条 user 的 content[0] 必须是日期块,实得 %q", s) - } - }) -} diff --git a/internal/core/channels_zcode_test.go b/internal/core/channels_zcode_test.go deleted file mode 100644 index 1851e94..0000000 --- a/internal/core/channels_zcode_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package core - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "modelrouter/internal/lua" -) - -// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。 -// -// 参考语义(zcode-login.ts 逐行): -// -// ① POST /api/v1/oauth/cli/init → {flow_id, authorize_url, poll_interval_sec} -// Authorization 是**本地造的会话密钥**(不是用户凭据) -// ② 用户在浏览器打开 authorize_url -// ③ GET /api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}} -// 判据:4xx(除 408/429)= 终态失败;5xx/网络错误 = 继续等; -// code!=0 或 data 缺失 = pending;status=pending = 继续 -// -// 此前标 flow="manual"(理由"要解 captcha"),但该授权流**完全不涉及 captcha** -// —— captcha 属于另一条已废弃的旁路(读取本机官方客户端数据)。 -type zcodeHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - polls int32 - readyAt int32 - gotInit map[string]interface{} - gotAuth string -} - -func newZcodeHarness(t *testing.T) *zcodeHarness { - t.Helper() - h := &zcodeHarness{t: t, readyAt: 2} - h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch { - case r.Method == "POST" && r.URL.Path == "/api/v1/oauth/cli/init": - body, _ := io.ReadAll(r.Body) - _ = json.Unmarshal(body, &h.gotInit) - h.gotAuth = r.Header.Get("Authorization") - fmt.Fprint(w, `{"code":0,"data":{"flow_id":"FLOW-1","poll_token":"PT", - "authorize_url":"https://bigmodel.cn/login?appId=zcode&redirect=x", - "expires_at":9999999999,"poll_interval_sec":2}}`) - case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v1/oauth/cli/poll/"): - if !strings.HasSuffix(r.URL.Path, "FLOW-1") { - fmt.Fprint(w, `{"code":1,"msg":"unknown flow"}`) - return - } - n := atomic.AddInt32(&h.polls, 1) - if n < atomic.LoadInt32(&h.readyAt) { - fmt.Fprint(w, `{"code":0,"data":{"status":"pending"}}`) - return - } - fmt.Fprint(w, `{"code":0,"data":{"status":"ready", - "token":"ZCODE-JWT-1", - "user":{"user_id":"u7","name":"zcode-dev"}, - "bigmodel":{"access_token":"BM-AT-1","refresh_token":"BM-RT-1"}}}`) - default: - w.WriteHeader(404) - } - })) - t.Cleanup(h.ts.Close) - - c := kernelTestCore(t) - h.ps = c.Plugins() - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - if err := h.ps.LoadSource("channels", src); err != nil { - t.Fatal(err) - } - _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) - if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "zcode", - "initpoll": map[string]interface{}{ - "init_url": h.ts.URL + "/api/v1/oauth/cli/init", - "poll_url": h.ts.URL + "/api/v1/oauth/cli/poll/{flow_id}", - }, - }); err != nil { - t.Fatalf("set_endpoints: %v", err) - } - return h -} - -func (h *zcodeHarness) begin() (map[string]interface{}, string) { - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin", - }) - if err != nil { - h.t.Fatal(err) - } - j := out.JSON.(map[string]interface{}) - return j, j["session"].(map[string]interface{})["id"].(string) -} - -func (h *zcodeHarness) pollOnce(sid string) map[string]interface{} { - out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", - }) - if err != nil { - h.t.Fatal(err) - } - return out.JSON.(map[string]interface{}) -} - -// TestZcodeInitPollBehavior 逐行锁定参考的 ZCode 授权流语义。 -func TestZcodeInitPollBehavior(t *testing.T) { - h := newZcodeHarness(t) - - t.Run("行1 init带本地会话密钥与provider", func(t *testing.T) { - atomic.StoreInt32(&h.polls, 0) - j, _ := h.begin() - if h.gotInit["provider"] != "bigmodel" { - t.Fatalf("init body 应带 provider=bigmodel, 实得 %v", h.gotInit) - } - // Authorization 是我们自己生成的会话密钥(Bearer 后非空),不是用户凭据 - if !strings.HasPrefix(h.gotAuth, "Bearer ") || len(h.gotAuth) < 20 { - t.Fatalf("init 应带本地会话密钥作 Bearer, 实得 %q", h.gotAuth) - } - if u, _ := j["url"].(string); !strings.Contains(u, "bigmodel.cn") { - t.Fatalf("应给出 authorize_url, 实得 %q", u) - } - }) - - t.Run("行2 pending状态保持pending", func(t *testing.T) { - atomic.StoreInt32(&h.polls, 0) - atomic.StoreInt32(&h.readyAt, 99) - _, sid := h.begin() - for i := 0; i < 3; i++ { - if st := h.pollOnce(sid); st["state"] != "pending" { - t.Fatalf("status=pending 应保持 pending, 实得 %v", st["state"]) - } - } - }) - - t.Run("行3 ready即完成_凭据含zcodeJwt与第三方token", func(t *testing.T) { - atomic.StoreInt32(&h.polls, 0) - atomic.StoreInt32(&h.readyAt, 2) - _, sid := h.begin() - if st := h.pollOnce(sid); st["state"] != "pending" { - t.Fatalf("首拍应 pending: %v", st) - } - st := h.pollOnce(sid) - if st["state"] != "completed" { - t.Fatalf("ready 应 completed, 实得 %v", st) - } - v, ok, _ := h.ps.GetSecret("channels", "channels:zcode") - if !ok { - t.Fatal("凭据未落库") - } - for _, want := range []string{"ZCODE-JWT-1", "BM-AT-1", "zcode-dev"} { - if !strings.Contains(v, want) { - t.Fatalf("凭据应含 %q, 实得 %s", want, v) - } - } - }) - - t.Run("行4 池路径_凭据落账号ref", func(t *testing.T) { - atomic.StoreInt32(&h.polls, 0) - atomic.StoreInt32(&h.readyAt, 1) - add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin", - }) - if err != nil { - t.Fatal(err) - } - aid := add.JSON.(map[string]interface{})["account_id"].(string) - out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: fmt.Sprintf(`{"platform":"zcode","account_id":%q}`, aid), Role: "admin", - }) - sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string) - if st := h.pollOnce(sid); st["state"] != "completed" { - t.Fatalf("池路径应完成: %v", st) - } - if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok { - t.Fatalf("池路径凭据未落到 channels:zcode:%s", aid) - } - }) -} diff --git a/internal/core/codearts_sign_test.go b/internal/core/codearts_sign_test.go deleted file mode 100644 index e3af629..0000000 --- a/internal/core/codearts_sign_test.go +++ /dev/null @@ -1,97 +0,0 @@ -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_harness_test.go b/internal/core/device_harness_test.go deleted file mode 100644 index 2447235..0000000 --- a/internal/core/device_harness_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package core - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "modelrouter/internal/lua" -) - -// readBundledChannels 读内置 channels 插件源(供行为/变异测试共用)。 -func readBundledChannels(t *testing.T) (string, error) { - t.Helper() - return lua.ReadBundledPlugin("channels") -} - -// deviceHarness 是 device-poll 决策表的可复用测试台:假上游(脚本化逐次响应) -// + channels 插件加载指定源码 + minimax 端点指到假上游。 -// 行为测试(TestChannelsDevicePollBehavior)与变异测试共用它 —— 决策表只有 -// 一份,两处不会漂移。 -type deviceHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - step int32 - // script 持续被 HTTP handler 原子读取 - script atomic.Value // []scriptStep -} - -type scriptStep struct { - http int - body string -} - -func newDeviceHarness(t *testing.T, src string) *deviceHarness { - t.Helper() - h := &deviceHarness{t: t} - h.script.Store([]scriptStep{}) - - h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/oauth2/device/code": - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"device_code":"DEV1","user_code":"ABCD-EFGH","verification_uri":"https://example/activate","interval":1,"expires_in":600}`) - case "/oauth2/token": - i := int(atomic.AddInt32(&h.step, 1)) - 1 - script := h.script.Load().([]scriptStep) - w.Header().Set("Content-Type", "application/json") - if i >= len(script) { - w.WriteHeader(500) - fmt.Fprint(w, `{"error":"unexpected"}`) - return - } - if script[i].http != 0 && script[i].http != 200 { - w.WriteHeader(script[i].http) - } - fmt.Fprint(w, script[i].body) - default: - w.WriteHeader(404) - } - })) - t.Cleanup(h.ts.Close) - - c := kernelTestCore(t) - h.ps = c.Plugins() - if err := h.ps.LoadSource("channels", src); err != nil { - t.Fatalf("加载: %v", err) - } - if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ - "platform": "minimax", - "device": map[string]interface{}{ - "code_url": h.ts.URL + "/oauth2/device/code", - "token_url": h.ts.URL + "/oauth2/token", - }, - }); err != nil { - t.Fatalf("set_endpoints: %v", err) - } - return h -} - -// setScript 设置 token 端点的逐次响应脚本。`400 {...}` 形式表示非 200。 -func (h *deviceHarness) setScript(steps ...string) { - var out []scriptStep - for _, b := range steps { - code := 200 - switch { - case strings.HasPrefix(b, "400 "): - code, b = 400, b[4:] - case strings.HasPrefix(b, "403 "): - code, b = 403, b[4:] - } - out = append(out, scriptStep{http: code, body: b}) - } - h.script.Store(out) - atomic.StoreInt32(&h.step, 0) -} - -func (h *deviceHarness) begin() map[string]interface{} { - out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ - Method: "POST", Body: `{"platform":"minimax"}`, - }) - if err != nil { - h.t.Fatal(err) - } - return out.JSON.(map[string]interface{}) -} - -func (h *deviceHarness) poll(id string) map[string]interface{} { - out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ - Method: "GET", Query: map[string]string{"id": id}, - }) - return out.JSON.(map[string]interface{}) -} - -// runDecisionTable 跑完整 8 行决策表,全部符合预期返回 true。 -// 这是行为契约的单一事实来源:行为测试用它断言通过;变异测试用它杀变异体。 -func (h *deviceHarness) runDecisionTable() bool { - ok := func(cond bool) bool { return cond } - st := func(steps ...string) map[string]interface{} { - h.setScript(steps...) - j := h.begin() - sid := j["session"].(map[string]interface{})["id"].(string) - return h.poll(sid) - } - - // 行1: 200+pending → 等 - if j := st(`{"status":"pending"}`); j["state"] != "pending" { - return false - } - // 行2: 200+slow_down → 等 + interval+5 - h.setScript(`{"status":"slow_down"}`) - j := h.begin() - sid := j["session"].(map[string]interface{})["id"].(string) - if j2 := h.poll(sid); j2["state"] != "pending" { - return false - } else if iv, _ := j2["interval"].(float64); iv < 6 { - return false - } - // 行3: 200+denied → failed,message 区分于通用错误(杀"denied 不识别"变异体) - if j := st(`{"status":"denied"}`); j["state"] != "failed" { - return false - } else if msg, _ := j["message"].(string); !strings.Contains(msg, "用户拒绝") { - return false - } - // 行4: 200+expired_token → failed,message 带过期字样 - if j := st(`{"status":"expired_token"}`); j["state"] != "failed" { - return false - } else if msg, _ := j["message"].(string); !strings.Contains(msg, "过期") { - return false - } - // 行5: 400+authorization_pending → 等 - if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" { - return false - } - // 行6: 403+slow_down → 等 + interval+5 - h.setScript(`403 {"error":"slow_down"}`) - j = h.begin() - sid = j["session"].(map[string]interface{})["id"].(string) - if j2 := h.poll(sid); j2["state"] != "pending" { - return false - } else if iv, _ := j2["interval"].(float64); iv < 6 { - return false - } - // 行7: 200+access_token → completed(需先授权 add_source) - _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false) - h.setScript(`{"access_token":"MM-ACCESS-1","refresh_token":"MM-RF-1","nickname":"mm-user"}`) - j = h.begin() - sid = j["session"].(map[string]interface{})["id"].(string) - if j2 := h.poll(sid); j2["state"] != "completed" || j2["account"] != "mm-user" { - return false - } - // 行8: 其它 error → failed 带文本 - if j := st(`{"error":"server_error"}`); j["state"] != "failed" { - return false - } - return ok(true) -} diff --git a/internal/core/device_mutate_test.go b/internal/core/device_mutate_test.go deleted file mode 100644 index 528e6d6..0000000 --- a/internal/core/device_mutate_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// TestDevicePollMutants 是 device-poll 执行器的变异测试。 -// -// 用户要求:移植要照参考实现的行为形成测试项,且要变异验证测试真的能杀掉 -// 「行为走样」的代码。每个用例把 channels 源里的一个决策分支做语义变异, -// 断言决策表**会失败**(变异体被杀)。变异体幸存 = 该分支没有覆盖。 -func TestDevicePollMutants(t *testing.T) { - src, err := readBundledChannels(t) - if err != nil { - t.Fatal(err) - } - - // 锚点会命中 deviceOneStep 里两处同形代码(workos 分支 + minimax 分支), - // 全部替换:两个决策表(minimax + workos)都会杀,任一幸存即失败。 - muts := []struct { - name string - from string - to string - }{ - { - name: "slow_down 不再加 interval", - from: `sess.device_interval = (sess.device_interval or d.interval or 5) + step`, - to: `sess.device_interval = (sess.device_interval or d.interval or 5)`, - }, - { - name: "denied 被当成继续等待", - from: `if resp.status == 200 and (status == "denied" or status == "access_denied") then`, - to: `if resp.status == 200 and (status == "denied_x" or status == "access_denied") then`, - }, - { - name: "expired_token 不被识别", - from: `if resp.status == 200 and (status == "expired" or status == "expired_token") then`, - to: `if resp.status == 200 and (status == "expired" or status == "expired_token_x") then`, - }, - { - name: "authorization_pending 被当成失败", - from: `if ecode == "authorization_pending" then return false end`, - to: `if ecode == "authorization_pending_x" then return false end`, - }, - { - name: "成功分支被禁用", - from: `if resp.status == 200 and rec.access_token then`, - to: `if resp.status == 200 and rec.access_token and false then`, - }, - } - - for _, mu := range muts { - t.Run(mu.name, func(t *testing.T) { - if !strings.Contains(src, mu.from) { - t.Fatalf("锚点丢失,先更新实现与锚点:%q", mu.from) - } - // 锚点在 deviceOneStep 的 workos/minimax 两个分支各有一份同形代码, - // 全部替换:任一分支的变异体被任一决策表杀掉才算有覆盖。 - mutated := strings.ReplaceAll(src, mu.from, mu.to) - if mutated == src { - t.Fatal("变异未生效") - } - // 基准必须全过(测试自身有效),变异体必须被杀(有覆盖) - if !newDeviceHarness(t, src).runDecisionTable() { - t.Fatal("基准决策表未全过 —— 测试自身失效") - } - if newDeviceHarness(t, mutated).runDecisionTable() { - t.Fatalf("变异体幸存(%s)—— 该分支无覆盖", mu.name) - } - }) - } -} diff --git a/internal/gateway/api.go b/internal/gateway/api.go index 9606eec..978f95b 100644 --- a/internal/gateway/api.go +++ b/internal/gateway/api.go @@ -215,7 +215,7 @@ const keepExistingAPIKey = "__KEEP__" // - the explicit mask keepExistingAPIKey — always inherit. // - the empty string — inherit only for a source that already has a // credential. Empty is a legitimate value for a *new* credential-less -// source (dshcodebuddy uses "dsh-local-noauth", local servers use ""), +// source (some local runtimes use a no-auth sentinel, local servers ""), // so a fresh source with an empty key must stay empty rather than inherit // nothing and fail differently. func (g *Gateway) resolveAPIKey(name, want string, exists bool) string { diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index d11eaff..af68174 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -489,38 +489,6 @@ func TestImageAutoFallsOnlyToImageProviders(t *testing.T) { } } -func TestKimicodeSigning(t *testing.T) { - var gotAuth, gotSign string - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = io.ReadAll(r.Body) - gotAuth = r.Header.Get("Authorization") - gotSign = r.Header.Get("X-App-Sign") - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`) - })) - defer up.Close() - - g := newTestGateway(t, config.Source{ - Name: "kimi", - BaseURL: up.URL, - Adapter: "kimicode", - APIKey: "sk-kimi", - Models: []config.Model{{ID: "kimi-k2"}}, - Meta: map[string]interface{}{"app_id": "app-1", "app_secret": "s3cr3t", "app_agent": "code-agent", "api_key": "sk-kimi"}, - }) - rr := doReq(t, g, "POST", "/v1/chat/completions", - `{"model":"kimi-k2","messages":[{"role":"user","content":"hi"}]}`) - if rr.Code != 200 { - t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) - } - if gotAuth == "" || !strings.Contains(gotAuth, "sk-kimi") { - t.Fatalf("expected signed auth, got %q", gotAuth) - } - if gotSign == "" { - t.Fatalf("expected app signature header") - } -} - func TestModelRoutingPrefix(t *testing.T) { up := mockUpstream() defer up.Close() diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 890113f..300ccbe 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -169,7 +169,7 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) { } // /api/plugins/{name}/login — REMOVED. Login sessions are plugin domain: - // channels.lua owns its own session state and serves it through its own + // A plugin owns its own session state and serves it through its own // mount. The kernel has no login concept. // /api/plugins/{name}/call/{fn} diff --git a/internal/lua/adapter_buddy_test.go b/internal/lua/adapter_buddy_test.go deleted file mode 100644 index 348fdcc..0000000 --- a/internal/lua/adapter_buddy_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package lua - -import ( - "encoding/json" - "strings" - "testing" -) - -// loadBuddyAdapter 装载内置 buddy 适配器。 -func loadBuddyAdapter(t *testing.T) *VM { - t.Helper() - src, err := bundledAdapters.ReadFile("adapters/buddy.lua") - if err != nil { - t.Fatal(err) - } - vm := NewVM(t.TempDir()) - t.Cleanup(vm.Stop) - if err := vm.LoadAdapterSource("buddy", string(src)); err != nil { - t.Fatalf("装载失败: %v", err) - } - return vm -} - -// ★ buddy 适配器:两个厂商要求,各有实测依据。 -// -// ① glm-5.1 **必须**带非空 reasoning_effort(否则上游 400 + 11150) -// ② 流式请求必须带 Accept: text/event-stream(否则只回空帧、completion_tokens=0) -func TestBuddyAdapterEffortAndHeaders(t *testing.T) { - vm := loadBuddyAdapter(t) - - // ① glm-5.1 → 补 medium - out, err := vm.Transform("buddy", "transform_request", - `{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`) - if err != nil { - t.Fatal(err) - } - var b map[string]interface{} - if err := json.Unmarshal([]byte(out), &b); err != nil { - t.Fatalf("%v\n%s", err, out) - } - if b["reasoning_effort"] != "medium" { - t.Errorf("glm-5.1 必须补 reasoning_effort=medium(否则上游 11150),实得 %v", b["reasoning_effort"]) - } - - // ② 其它模型**不补**(避免把单模型要求扩成全平台行为) - out2, err := vm.Transform("buddy", "transform_request", - `{"model":"hy3","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`) - if err != nil { - t.Fatal(err) - } - var b2 map[string]interface{} - json.Unmarshal([]byte(out2), &b2) - if b2["reasoning_effort"] != nil { - t.Errorf("hy3 不该被补 reasoning_effort(实测不需要),实得 %v", b2["reasoning_effort"]) - } - - // ③ 调用方已给档位时**尊重它** - out3, _ := vm.Transform("buddy", "transform_request", - `{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high","stream":true}`) - var b3 map[string]interface{} - json.Unmarshal([]byte(out3), &b3) - if b3["reasoning_effort"] != "high" { - t.Errorf("调用方给的档位必须保留,实得 %v", b3["reasoning_effort"]) - } - - // ④ 头:Accept 必须是 SSE - h, err := vm.BuildHeaders("buddy", map[string]interface{}{"api_key": "TOK"}) - if err != nil { - t.Fatal(err) - } - if h["Accept"] != "text/event-stream" { - t.Errorf("Accept 必须是 text/event-stream(否则上游只回空帧),实得 %q", h["Accept"]) - } - for _, k := range []string{"X-Domain", "X-Product", "X-Product-Code"} { - if h[k] == "" { - t.Errorf("缺少 buddy 身份头 %s", k) - } - } - t.Logf("✓ glm-5.1 补 effort;hy3 不补;Accept=%s", h["Accept"]) -} - -// 配对判据:协议转换必须与 openai 一致(buddy 只是补两个字段,不该改转换)。 -func TestBuddyAdapterKeepsOpenAIWire(t *testing.T) { - vm := loadBuddyAdapter(t) - chunk := `{"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"hi","reasoning_content":"think"},"finish_reason":null}]}` - out, err := vm.Transform("buddy", "transform_stream_chunk", chunk) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, `"content":"hi"`) || !strings.Contains(out, `"reasoning_content":"think"`) { - t.Errorf("流式转换必须保留 content 与 reasoning_content,实得 %s", out) - } -} - -// ★ 回归护栏:适配器**必须**定义全部四个转换函数。 -// -// 真实事故(2026-10-16):我第一版把基座继承写成"在适配器里读兄弟适配器源码", -// 而适配器运行在**另一个沙箱 VM**(无 __llmsproxy_plugin_dir、无文件系统), -// 于是基座加载**静默失败** ⇒ 适配器只有 transform_request, -// **缺 transform_stream_chunk** ⇒ 内核无法解析 SSE ⇒ 上游明明回了 39 帧正文, -// 网关却记 completion_tokens=0、客户端看到**空回复**。 -// 更糟的是:hy3/glm-5.3/kimi-k3-1 全被它弄坏,而 glm-5.2 因多源兜底 -// "看起来正常" ⇒ 症状指向错误方向。 -// -// ⇒ 判据必须咬住「四个转换函数都存在」,而不是"某个模型能回话" -// -// (后者会被别的源兜底掩盖 —— 本会话已栽过两次)。 -func TestBuddyAdapterDefinesAllTransforms(t *testing.T) { - vm := loadBuddyAdapter(t) - for _, fn := range []string{"transform_request", "transform_response", "transform_stream_chunk", "transform_error"} { - if !vm.HasFn("buddy", fn) { - t.Errorf("适配器缺 %s ⇒ 内核该路径会静默降级(流式会变成空回复)", fn) - } - } - t.Log("✓ 四个转换函数齐备") -} diff --git a/internal/lua/adapter_opencodezen_live_test.go b/internal/lua/adapter_opencodezen_live_test.go deleted file mode 100644 index 84d93a6..0000000 --- a/internal/lua/adapter_opencodezen_live_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package lua - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "strings" - "testing" - "time" -) - -// 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。 -// 需要真实凭据(从生产配置解密),故用 MODELROUTER_LIVE_ZEN=1 显式开启。 -func TestOpencodeZenLiveE2E(t *testing.T) { - if os.Getenv("MODELROUTER_LIVE_ZEN") != "1" { - t.Skip("设 MODELROUTER_LIVE_ZEN=1 开启真上游 e2e") - } - src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") - if err != nil { - t.Fatal(err) - } - vm := NewVM(t.TempDir()) - defer vm.Stop() - if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { - t.Fatal(err) - } - - // 取生产凭据(解密) - out, err := exec.Command(gatewayBinary(), "-show-secrets", - "-config", "/etc/modelrouter/config.yaml").Output() - if err != nil { - t.Fatal(err) - } - apiKey := "" - for _, l := range strings.Split(string(out), "\n") { - if strings.HasPrefix(l, "source localzen") { - if i := strings.Index(l, "api_key="); i >= 0 { - apiKey = strings.TrimSpace(l[i+len("api_key="):]) - } - } - } - if apiKey == "" { - t.Fatal("未取到 localzen 凭据") - } - - cases := []struct { - name string - body string - want string - }{ - {"纯对话(无 tools,靠适配器兜底注入)", - `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, - "messages":[{"role":"user","content":"reply with exactly: ADAPTER-OK"}]}`, "ADAPTER-OK"}, - {"带自定义 system", - `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, - "messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"reply with exactly: SYS-OK"}]}`, "SYS-OK"}, - {"多轮", - `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, - "messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":"reply with exactly: MULTI-OK"}]}`, "MULTI-OK"}, - {"算术(验证真推理)", - `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, - "messages":[{"role":"user","content":"What is 17*23? Reply with just the number."}]}`, "391"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - body, err := vm.Transform("opencodezen", "transform_request", tc.body) - if err != nil { - t.Fatal(err) - } - h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{ - "timestamp": time.Now().Unix(), "body": body, - }) - if err != nil { - t.Fatal(err) - } - req, _ := http.NewRequest("POST", - "https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body))) - for k, v := range h { - req.Header.Set(k, v) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - cl := &http.Client{Timeout: 90 * time.Second} - resp, err := cl.Do(req) - if err != nil { - t.Fatal(err) - } - raw, _ := io.ReadAll(resp.Body) - resp.Body.Close() - if resp.StatusCode != 200 { - t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))]) - } - // 上游现在返回 SSE(适配器强制 stream=true);判据需自己拼 delta.content。 - got := "" - for _, line := range strings.Split(string(raw), "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "data: ") { - continue - } - p := strings.TrimPrefix(line, "data: ") - if p == "[DONE]" { - continue - } - var d struct { - Choices []struct { - Delta struct{ Content string } `json:"delta"` - } `json:"choices"` - } - if json.Unmarshal([]byte(p), &d) != nil || len(d.Choices) == 0 { - continue - } - got += d.Choices[0].Delta.Content - } - if got == "" { - t.Fatalf("未从 SSE 解出内容;原始=%s", string(raw)[:min(300, len(raw))]) - } - if !strings.Contains(got, tc.want) { - t.Fatalf("回复 %q 不含 %q", got, tc.want) - } - t.Logf("✓ %s → %q", tc.name, strings.TrimSpace(got)) - }) - } - fmt.Println("真上游 e2e 全部通过") -} - -// 经适配器完整转换链:上游 SSE → transform_stream_chunk → 统一格式。 -// 上一条判据直接读原始 SSE,只证明"上游放行";这条证明"适配器能解析"。 -func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) { - if os.Getenv("MODELROUTER_LIVE_ZEN") != "1" { - t.Skip("设 MODELROUTER_LIVE_ZEN=1 开启") - } - src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") - if err != nil { - t.Fatal(err) - } - vm := NewVM(t.TempDir()) - defer vm.Stop() - if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { - t.Fatal(err) - } - out, _ := exec.Command(gatewayBinary(), "-show-secrets", - "-config", "/etc/modelrouter/config.yaml").Output() - apiKey := "" - for _, l := range strings.Split(string(out), "\n") { - if strings.HasPrefix(l, "source localzen") { - if i := strings.Index(l, "api_key="); i >= 0 { - apiKey = strings.TrimSpace(l[i+len("api_key="):]) - } - } - } - - body, _ := vm.Transform("opencodezen", "transform_request", - `{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60, - "messages":[{"role":"user","content":"reply with exactly: CHAIN-OK"}]}`) - h, _ := vm.BuildHeaders("opencodezen", map[string]interface{}{ - "timestamp": time.Now().Unix(), "body": body}) - - req, _ := http.NewRequest("POST", - "https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body))) - for k, v := range h { - req.Header.Set(k, v) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req) - if err != nil { - t.Fatal(err) - } - raw, _ := io.ReadAll(resp.Body) - resp.Body.Close() - if resp.StatusCode != 200 { - t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))]) - } - - // 逐行喂给适配器的流解析器,累积统一格式的 content - joined := "" - n := 0 - for _, line := range strings.Split(string(raw), "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { - continue - } - // 内核喂给适配器的是**已剥掉 data: 前缀**的载荷(见 provider.go: - // strings.TrimPrefix(line, "data:")),判据必须对齐这个契约。 - payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - unified, err := vm.Transform("opencodezen", "transform_stream_chunk", payload) - if err != nil { - t.Fatalf("transform_stream_chunk: %v (%s)", err, line[:min(120, len(line))]) - } - if unified == "" || unified == "{}" { - continue - } - var u struct { - Content string `json:"content"` - } - if json.Unmarshal([]byte(unified), &u) == nil { - joined += u.Content - n++ - } - } - if n == 0 { - t.Fatal("适配器未解出任何 chunk(流解析链断了)") - } - if !strings.Contains(joined, "CHAIN-OK") { - t.Fatalf("经适配器链的内容 %q 不含 CHAIN-OK(chunk 数=%d)", joined, n) - } - t.Logf("✓ 完整链:%d 个 chunk,内容=%q", n, strings.TrimSpace(joined)) -} - -// gatewayBinary 定位本机网关二进制。改名后默认是 /usr/local/bin/modelrouter; -// 仍兼容改名前的 /usr/local/bin/llmsproxy,避免测试脚本要跟着运维机器改。 -func gatewayBinary() string { - if v := os.Getenv("MODELROUTER_BIN"); v != "" { - return v - } - for _, p := range []string{"/usr/local/bin/modelrouter", "/usr/local/bin/llmsproxy"} { - if _, err := os.Stat(p); err == nil { - return p - } - } - return "/usr/local/bin/modelrouter" -} diff --git a/internal/lua/adapter_opencodezen_reasoning_test.go b/internal/lua/adapter_opencodezen_reasoning_test.go deleted file mode 100644 index d2c8670..0000000 --- a/internal/lua/adapter_opencodezen_reasoning_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package lua - -import ( - "encoding/json" - "strings" - "testing" -) - -// loadZenVM 装载 opencodezen 适配器(照 adapter_opencodezen_test.go 的方式)。 -func loadZenVM(t *testing.T) *VM { - t.Helper() - src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") - if err != nil { - t.Fatal(err) - } - vm := NewVM(t.TempDir()) - t.Cleanup(vm.Stop) - if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { - t.Fatalf("加载失败: %v", err) - } - return vm -} - -// ★ assistant 回合的 reasoning_content **必须回传**给 opencode 上游。 -// -// 真实缺陷(生产实测,2026-10-16): -// -// provider localzen: api error 400: Upstream request failed: [invalid_request_error] -// The `reasoning_content` in the thinking mode must be passed back to the API. -// -// 根因:transform_request 里写了 `msg.reasoning_content = nil`,理由是 -// "Zen 免费池不接受回传"。该假设**方向反了** —— 上游要的就是它。 -// -// ★★ 这个缺陷极难归因,因为失败是**交替**出现的: -// 只有当上一轮**真的产出了 reasoning**(thinking 模型 + reasoning_effort 开启) -// 时,回放那段历史才会被拒;普通回合照常 200。审计里表现为成串 502 紧随一个 -// 成功的长上下文请求(实测 prompt_tokens=139330)之后。 -// -// 判据必须**咬住「被剥掉」这个具体行为**,而不是"请求成功" —— 后者在 -// 非 thinking 回合下永远为真,测不出这个缺陷。 -func TestOpenCodeZenKeepsAssistantReasoningContent(t *testing.T) { - vm := loadZenVM(t) - - body := map[string]interface{}{ - "model": "deepseek-v4.1-flash-free", - "stream": true, - "messages": []map[string]interface{}{ - {"role": "user", "content": "1+1=?"}, - { - "role": "assistant", - "content": "2", - // 这段正是 agent 回放历史时会带上的东西 - "reasoning_content": "一加一等于二。", - }, - {"role": "user", "content": "再加一"}, - }, - } - raw, err := json.Marshal(body) - if err != nil { - t.Fatal(err) - } - out, err := vm.Transform("opencodezen", "transform_request", string(raw)) - if err != nil { - t.Fatal(err) - } - - // 解析出站体,找 assistant 回合 - var sent struct { - Messages []map[string]interface{} `json:"messages"` - } - if err := json.Unmarshal([]byte(out), &sent); err != nil { - t.Fatalf("出站体不是合法 JSON: %v\n%s", err, out) - } - var found bool - for _, m := range sent.Messages { - if m["role"] != "assistant" { - continue - } - found = true - rc, _ := m["reasoning_content"].(string) - if rc == "" { - t.Fatalf("assistant 回合的 reasoning_content 被剥掉了 ⇒ 上游会回 "+ - "`The reasoning_content in the thinking mode must be passed back to the API`\n"+ - "出站 messages: %s", out) - } - if rc != "一加一等于二。" { - t.Errorf("reasoning_content 被改写:%q", rc) - } - } - if !found { - t.Fatalf("没找到 assistant 回合(判据失效)\n%s", out) - } - t.Log("✓ assistant 的 reasoning_content 已保留") -} - -// ★ 反向:**空的** reasoning_content 应被丢掉(不要把空串发给上游)。 -// 与上一条配对 —— 缺了这条,"改成无条件保留"这类过度修正不会被发现。 -func TestOpenCodeZenDropsEmptyReasoningContent(t *testing.T) { - vm := loadZenVM(t) - - raw := `{"model":"m","stream":true,"messages":[ - {"role":"assistant","content":"hi","reasoning_content":""} - ]}` - out, err := vm.Transform("opencodezen", "transform_request", raw) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, `"reasoning_content":""`) { - t.Errorf("空的 reasoning_content 不该发给上游(多余字段,部分上游会挑),实得: %s", out) - } - t.Log("✓ 空 reasoning_content 已丢弃") -} diff --git a/internal/lua/adapter_opencodezen_test.go b/internal/lua/adapter_opencodezen_test.go deleted file mode 100644 index f1d979f..0000000 --- a/internal/lua/adapter_opencodezen_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package lua - -import ( - "encoding/json" - "strings" - "testing" -) - -// E2E:加载改后的适配器,验证 (1) 头含两个 session 头且值相同;(2) id 段服从公式; -// (3) 无 tools 的请求被注入 read 桩且 properties 非空。 -func TestOpencodeZenAdapterE2E(t *testing.T) { - src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") - if err != nil { - t.Fatal(err) - } - vm := NewVM(t.TempDir()) - defer vm.Stop() - if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { - t.Fatalf("加载失败: %v", err) - } - - t.Run("头:两个 session 头同值", func(t *testing.T) { - h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{"timestamp": int64(1792065049)}) - if err != nil { - t.Fatal(err) - } - s1, s2 := h["x-opencode-session"], h["x-opencode-session-id"] - if s1 == "" || s2 == "" { - t.Fatalf("两个 session 头都必须存在: %#v", h) - } - if s1 != s2 { - t.Fatalf("必须同值: %q vs %q", s1, s2) - } - if !strings.HasPrefix(s1, "ses_") || len(s1) != 4+12+14 { - t.Fatalf("session 形状应为 ses_+12hex+14base62: %q (len=%d)", s1, len(s1)) - } - if len(h["x-opencode-request"]) != 4+12+14 || !strings.HasPrefix(h["x-opencode-request"], "msg_") { - t.Fatalf("request 形状: %q", h["x-opencode-request"]) - } - // ★ UA 必须与**抓包的官方版本**一致:上游按客户端指纹判定免费层资格。 - // 实测(2026-10-24,同一凭据同一模型): - // CLI 1.18.29(发 4 个头) → 429 FreeUsageLimitError - // CLI 1.19.4 (发 5 个头) → 200,任务真实完成 - // ⇒ UA 里声称的版本与发头集合必须同步升级,不能只改其一。 - if !strings.Contains(h["User-Agent"], "opencode/1.19.4") { - t.Fatalf("UA 应为 1.19.4: %q", h["User-Agent"]) - } - t.Logf("session=%s request=%s", s1, h["x-opencode-request"]) - // 公式校验:段 = (K - bucket_ms*4096) & MASK,其中 bucket 已对齐到整分钟 - // (适配器按分钟桶取会话号,以保留上游前缀缓存命中)。 - // ⚠️ 判据必须算**同一个桶**,不能拿原始 ms 去比 —— 那会把正确的实现判成错。 - seg := s1[4:16] - ms := int64(1792065049) * 1000 - bucket := ms - (ms % 60000) - want := (int64(0x1afffffffffffe) - bucket*4096) & 0xFFFFFFFFFFFF - if got := seg; got != fmtHex12(want) { - t.Fatalf("session 段公式不符: got=%s want=%s (bucket=%d)", got, fmtHex12(want), bucket) - } - }) - - t.Run("tools 兜底:无 tools 时注入 read", func(t *testing.T) { - out, err := vm.Transform("opencodezen", "transform_request", - `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatal(err) - } - var req map[string]interface{} - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatal(err) - } - ts, _ := req["tools"].([]interface{}) - if len(ts) == 0 { - t.Fatalf("必须注入 tools: %s", out) - } - found := false - for _, x := range ts { - fn := x.(map[string]interface{})["function"].(map[string]interface{}) - if fn["name"] == "read" { - p, _ := fn["parameters"].(map[string]interface{}) - props, _ := p["properties"].(map[string]interface{}) - if len(props) == 0 { - t.Fatalf("read 的 properties 必须非空(门禁要求): %s", out) - } - found = true - } - } - if !found { - t.Fatalf("必须含名为 read 的工具: %s", out) - } - }) - - t.Run("tools 保留:用户自带 read 不被覆盖", func(t *testing.T) { - in := `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}], - "tools":[{"type":"function","function":{"name":"read","description":"MY","parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}]}` - out, err := vm.Transform("opencodezen", "transform_request", in) - if err != nil { - t.Fatal(err) - } - var req map[string]interface{} - json.Unmarshal([]byte(out), &req) - ts, _ := req["tools"].([]interface{}) - if len(ts) != 1 { - t.Fatalf("不应追加(已有 read): %s", out) - } - fn := ts[0].(map[string]interface{})["function"].(map[string]interface{}) - if fn["description"] != "MY" { - t.Fatalf("用户定义被覆盖: %s", out) - } - }) -} - -func fmtHex12(v int64) string { - const hex = "0123456789abcdef" - out := make([]byte, 12) - for i := 11; i >= 0; i-- { - out[i] = hex[v&0xF] - v >>= 4 - } - return string(out) -} - -// ★ 免费层只能流式(适配器强制 stream=true),而内核在客户端要非流式时 -// 会调 transform_response(期望 JSON)⇒ 适配器必须能把 SSE 聚合回非流式对象。 -// 这条判据钉住那个聚合:真实 SSE 片段 → 完整 chat.completion。 -func TestOpencodeZenAggregatesSSEForNonStreamClient(t *testing.T) { - src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") - if err != nil { - t.Fatal(err) - } - vm := NewVM(t.TempDir()) - defer vm.Stop() - if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil { - t.Fatal(err) - } - // 模仿上游真实 SSE(含 reasoning_content 与 usage 末帧) - sse := `data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"think "}}]} -data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"HELLO"}}]} -data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":" WORLD"},"finish_reason":"stop"}]} -data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}} -data: [DONE] -` - out, err := vm.Transform("opencodezen", "transform_response", sse) - if err != nil { - t.Fatal(err) - } - var u map[string]interface{} - if err := json.Unmarshal([]byte(out), &u); err != nil { - t.Fatalf("聚合产物不是 JSON: %v (%s)", err, out) - } - if u["content"] != "HELLO WORLD" { - t.Fatalf("content 应为 'HELLO WORLD',实得 %v", u["content"]) - } - tu, _ := u["token_usage"].(map[string]interface{}) - if tu == nil || tu["prompt"] != float64(7) { - t.Fatalf("usage 应透传: %v", u["token_usage"]) - } - if u["finish_reason"] != "stop" { - t.Fatalf("finish_reason 应为 stop: %v", u["finish_reason"]) - } - t.Logf("聚合 OK: content=%v usage=%v finish=%v", u["content"], tu, u["finish_reason"]) -} diff --git a/internal/lua/adapter_prelude.go b/internal/lua/adapter_prelude.go index 83f2891..150f2fb 100644 --- a/internal/lua/adapter_prelude.go +++ b/internal/lua/adapter_prelude.go @@ -6,8 +6,8 @@ package 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) +// transform_stream_chunk github/groq/mistral 三份相同 +// transform_error deepseek/github/groq 三份相同 // // 合计 12680 字节的**完全重复**;再加上"近似相同"的部分(所有 OpenAI 兼容 // 适配器的 transform_request 都要剥 v2 私有控制字段、transform_response 都要 diff --git a/internal/lua/adapter_prelude_equivalence_test.go b/internal/lua/adapter_prelude_equivalence_test.go index 6081230..ff4c6dd 100644 --- a/internal/lua/adapter_prelude_equivalence_test.go +++ b/internal/lua/adapter_prelude_equivalence_test.go @@ -11,11 +11,11 @@ import ( // 比如 usage 的缓存命中只认了一种写法、tool_calls 的 arguments 忘了解码、 // usage-only chunk 被当成终结帧。这类差异不会报错,只会让下游少一个字段。 // -// 期望值是重构前那 5 个适配器(openai/groq/kimicode/mistral/github 的 +// 期望值是重构前的适配器(openai/groq/mistral/github 的 // transform_response 本就逐字节相同)对固定输入的真实输出,先抓 golden 再比。 func TestPreludeRefactorIsByteIdentical(t *testing.T) { vm, _, _ := runtimePlugins(t) - for _, name := range []string{"openai", "groq", "kimicode", "mistral", "github"} { + for _, name := range []string{"openai", "groq", "mistral", "github"} { src, err := bundledAdapters.ReadFile("adapters/" + name + ".lua") if err != nil { t.Fatalf("读内嵌适配器 %s: %v", name, err) @@ -30,7 +30,7 @@ func TestPreludeRefactorIsByteIdentical(t *testing.T) { `"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"} { + for _, n := range []string{"openai", "groq", "mistral", "github"} { out, err := vm.Transform(n, "transform_response", respIn) if err != nil { t.Fatalf("%s transform_response: %v", n, err) @@ -60,7 +60,7 @@ func TestPreludeRefactorIsByteIdentical(t *testing.T) { 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"} { + for _, n := range []string{"openai", "groq", "mistral", "github"} { out, err := vm.Transform(n, "transform_stream_chunk", streamIn) if err != nil { t.Fatalf("%s stream: %v", n, err) @@ -77,7 +77,7 @@ func TestPreludeRefactorIsByteIdentical(t *testing.T) { // 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"} { + for _, n := range []string{"openai", "groq", "mistral", "github"} { out, err := vm.Transform(n, "transform_stream_chunk", usageOnly) if err != nil { t.Fatalf("%s usage-only: %v", n, err) @@ -93,7 +93,7 @@ func TestPreludeRefactorIsByteIdentical(t *testing.T) { // 错误体抽取:OpenAI 风格 {error:{message}} → 消息本身 // (这段在 6 个适配器里原本逐字节相同) - for _, n := range []string{"deepseek", "github", "groq", "kimicode", "openai", "sensenova"} { + for _, n := range []string{"deepseek", "github", "groq", "openai", "sensenova"} { src, err := bundledAdapters.ReadFile("adapters/" + n + ".lua") if err != nil { t.Fatalf("读 %s: %v", n, err) diff --git a/internal/lua/adapter_prelude_error_shapes_test.go b/internal/lua/adapter_prelude_error_shapes_test.go index 8efd947..9b862e1 100644 --- a/internal/lua/adapter_prelude_error_shapes_test.go +++ b/internal/lua/adapter_prelude_error_shapes_test.go @@ -10,14 +10,14 @@ import ( // // unhandled upstream error body (adapter "openai" lacks transform_error): // status=404 body={"error_msg":"404 Route Not Found"} -// unhandled upstream error body (adapter "buddy" lacks transform_error): +// unhandled upstream error body (an adapter without transform_error): // status=400 body={"code":11101,"msg":"Non-stream chat request is not supported"} // // 两者都是 `{"error_msg":...}` / `{"msg":...}` —— 旧实现只认 // error/message/detail,于是返回 nil ⇒ 客户端只看到 // `api error 404: unknown error`,而 body 里明明有可用文案。 // -// ⚠️ 那两个适配器**都定义了** transform_error(openai.lua:139、buddy.lua:166), +// ⚠️ openai 系适配器**都定义了** transform_error, // 内核却报 "lacks" ⇒ 一句会把排查带偏的诊断(已另行修正内核文案)。 func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) { vm := NewVM(t.TempDir()) @@ -37,7 +37,7 @@ func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) { }{ {"APISIX error_msg", `{"error_msg":"404 Route Not Found"}`, "404 Route Not Found"}, - {"腾讯 msg(buddy 11101)", + {"上游私有 msg 错误", `{"code":11101,"msg":"Non-stream chat request is currently not supported"}`, "11101: Non-stream chat request is currently not supported"}, {"error_code + error_msg", @@ -58,12 +58,12 @@ func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) { {"FastAPI detail", `{"detail":"boom"}`, "boom"}, {"HTML title", `413 Too Large`, "413 Too Large"}, {"裸文本", "plain failure line\nmore", "plain failure line"}, - // 腾讯 codebuddy 三层嵌套(实测 429 code=14018 额度用尽): + // 上游三层嵌套错误(实测 429 code=14018 quota exhausted): // 此前落到"不识别",客户端只看到通用文案,用户分不清是额度还是故障。 - {"buddy 额度用尽嵌套", `{"error":{"data":{"code":14018,"msg":"额度已用尽,请购买加量包","requestId":"r1"}}}`, + {"三层嵌套额度用尽", `{"error":{"data":{"code":14018,"msg":"额度已用尽,请购买加量包","requestId":"r1"}}}`, "14018: 额度已用尽,请购买加量包"}, // 同一形状但没有 code:只回 msg - {"buddy 嵌套无码", `{"error":{"data":{"msg":"请求被拒绝"}}}`, "请求被拒绝"}, + {"嵌套无码", `{"error":{"data":{"msg":"请求被拒绝"}}}`, "请求被拒绝"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/lua/adapter_zcode_base_test.go b/internal/lua/adapter_zcode_base_test.go deleted file mode 100644 index d6d1c01..0000000 --- a/internal/lua/adapter_zcode_base_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package lua - -import ( - "os" - "regexp" - "strings" - "testing" -) - -// ★ zcode 适配器**源码模板**必须真的能把 anthropic 基座合并进来。 -// -// 背景:此前适配器在**沙箱里自己读 anthropic.lua** ⇒ 必然失败(沙箱无 fs、 -// 无 plugin_dir)且**静默降级** ⇒ 只有它自己定义的 2 个函数,缺 7 个 -// ⇒ 每次调用内核报 "missing transform_response" ⇒ 渠道**完全不可用**。 -// 该缺陷一直潜伏,因为 zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。 -// -// ⚠️ **本判据的边界(务必如实理解)**:它在测试里**自己**做占位符替换, -// -// 所以它证明的是「**模板形态**正确 + 合并逻辑能吃下基座」, -// 而**不能**证明「installZcodeAdapter 真的内联了非空基座」—— -// 把安装处的 `asrc` 换成空串,本判据**不会**报红(已实测)。 -// 安装接线由 internal/core 的端到端判据覆盖: -// TestZcodeAdapterInjectsOfficialIdentityBlocks -// TestZcodeAdapterKeepsAnthropicProtocolConversion(工具调用/图像块存活) -// TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(基座不可得 ⇒ 安装期报错) -// 这三条**会**咬住"内联空基座"这个变异(已实测)。 -// ⇒ 教训:判据自己复现被测逻辑时,必须说清它到底覆盖了哪一段。 -func TestZcodeAdapterInheritsAnthropicBase(t *testing.T) { - psrc, _ := os.ReadFile("plugins/channels.lua") - s := string(psrc) - diag := regexp.MustCompile(`(?s)local ZCODE_DIAG_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1] - ad := regexp.MustCompile(`(?s)local ZCODE_ADAPTER_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1] - asrc, err := bundledAdapters.ReadFile("adapters/anthropic.lua") - if err != nil { - t.Fatal(err) - } - - full := "local Z = (function()\n" + diag + "\nend)()\n" + ad - // ⚠ 占位符不等于"适配器里能读到基座":真正要验的是**装载后 9 个函数都在**。 - // 故这里按 installZcodeAdapter 的同一手法内联,再逐函数断言。 - if strings.Contains(full, "__INLINE_ANTHROPIC_SRC__") { - full = strings.ReplaceAll(full, "__INLINE_ANTHROPIC_SRC__", quoteLua(string(asrc))) - } else { - t.Fatal("适配器源码必须有基座占位符(基座由插件主体内联;" + - "适配器沙箱里读不到文件)") - } - - vm := NewVM(t.TempDir()) - defer vm.Stop() - if err := vm.LoadAdapterSource("channels-zcode", full); err != nil { - t.Fatalf("装载失败: %v", err) - } - want := []string{"transform_request", "transform_response", "transform_stream_chunk", - "transform_error", "serialize_chunk", "serialize_close", "serialize_open_preamble", - "serialize_response", "deserialize_request"} - var lack []string - for _, f := range want { - if !vm.HasFn("channels-zcode", f) { - lack = append(lack, f) - } - } - if len(lack) > 0 { - t.Fatalf("★ zcode 仍缺 %d 个函数(%v)⇒ 基座继承失败 ⇒ "+ - "每次调用都会报 'missing ' ⇒ 渠道完全不可用", len(lack), lack) - } - // 行为验证:基座的 transform_response 必须真的能转换 anthropic 响应 - out, err := vm.Transform("channels-zcode", "transform_response", - `{"id":"m1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`) - if err != nil { - t.Fatalf("transform_response 调用失败: %v", err) - } - if !strings.Contains(out, "hi") { - t.Errorf("基座 transform_response 未产出内容: %s", out) - } - t.Logf("✓ zcode 继承基座成功(9/9 函数),transform_response → %s", out) -} - -func quoteLua(s string) string { - var b strings.Builder - b.WriteByte('"') - for i := 0; i < len(s); i++ { - switch c := s[i]; c { - case '"': - b.WriteString(`\"`) - case '\\': - b.WriteString(`\\`) - case '\n': - b.WriteString(`\n`) - case '\r': - b.WriteString(`\r`) - default: - b.WriteByte(c) - } - } - b.WriteByte('"') - return b.String() -} diff --git a/internal/lua/adapters/buddy.lua b/internal/lua/adapters/buddy.lua deleted file mode 100644 index d2a624f..0000000 --- a/internal/lua/adapters/buddy.lua +++ /dev/null @@ -1,292 +0,0 @@ -local adapter = {} - -adapter.name = "buddy" -adapter.version = "1.0.0" -adapter.endpoint = "/chat/completions" -adapter.headers = {} - --- Claude-behind-OpenAI upstreams (tabitoken, 扇贝, …) convert /chat/completions --- to the Anthropic Messages API internally, so they inherit Anthropic's --- tool id rule: ^[a-zA-Z0-9_-]{1,64}$, enforced by rejecting the WHOLE request --- with "Invalid tool use format" / "tool_use.id: String should match pattern". --- Plain OpenAI has no such rule, so an OpenAI-compatible model can mint an id --- like "bash:0" (observed from moonshotai/kimi-k3). In a fan-out router that id --- is replayed to every other source, so one such id kills every Claude slot at --- once and an AUTO request falls through all tiers. --- --- safe_tool_id is pure and deterministic, so a tool_calls entry and its --- matching tool_call_id are rewritten identically within one request. A --- rewritten id keeps an 8-hex digest of the ORIGINAL id, without which two --- distinct ids could collapse into one ("a:b" and "a_b") and become an --- unpaired/duplicate tool call. Already-legal ids are returned untouched, so --- well-behaved traffic is byte-identical to before. --- (anthropic.lua carries the same helper; Lua adapters have no shared prelude.) -local TOOL_ID_MAX = 64 - -local function safe_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 <= TOOL_ID_MAX then - return clean - end - local digest = string.sub(sha256_hex(id), 1, 8) - local keep = TOOL_ID_MAX - #digest - 1 - if #clean > keep then clean = string.sub(clean, 1, keep) end - return clean .. "_" .. digest -end - --- 需要补 reasoning_effort 的模型 → 补哪个档位。 --- --- 唯一出处:参考 product.ts:237 声明 glm-5.1 的 reasoningEfforts 只有 ['medium']。 --- 但实测**任何非空档位都被接受**,故这里取 medium(与参考声明一致)。 -local NEED_EFFORT = { - ["glm-5.1"] = "medium", -} - --- OpenAI /chat/completions format (pass-through, strip provider-specific fields) -function adapter.transform_request(raw_body) - local ok, req = pcall(json.decode, raw_body) - if not ok then return raw_body end - -- The gateway's private control fields are GONE from the body (v2): the - -- neutral `thinking` field carries that intent and each adapter maps it in - -- its own protocol terms. These upstreams have no thinking switch at all, - -- so the field is simply dropped here — which is what the nil-ing below - -- used to do, except the field being dropped is no longer the kernel's - -- private one. - req.thinking = nil - req.extra_body = nil - -- ★★ buddy(腾讯 CodeBuddy)上游**只支持流式**:body.stream=false 会被 - -- 400 拒绝(实测 2026-10-19): - -- 11101: Non-stream chat request is currently not supported - -- 与 Accept: text/event-stream 头无关(头早已在发),判据是 body。 - -- 故强制 stream=true,非流式请求由内核的"客户端要非流式、上游回流式" - -- 路径消化(transform_response 需能聚合 SSE,见下)。 - req.stream = true - -- ★★ buddy 的 glm-5.1 **要求请求体里有非空的 reasoning_effort**, - -- 否则上游回 400: - -- {"code":11150,"msg":"the reasoning effort value is not supported - -- by the current model"} - -- 实测(2026-10-16,逐字对照实验): - -- glm-5.1 裸(无该字段) → 400 11150 ✗ - -- glm-5.1 + reasoning_effort:medium → 200 ✓ - -- glm-5.1 空串 → 400 11150 ✗ - -- 任何**非空**档位均可(low/medium/high/xhigh/max/minimal 逐个 200) - -- 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)—— 即这是 - -- glm-5.1 **独有**的要求。 - -- ⚠️ 只对**已实测需要**的模型补(而不是一律补):一律补会把"某个模型的 - -- 特殊要求"扩成"全平台行为",将来上游若对某些模型收紧就会一次性弄坏 - -- 所有模型。 - if NEED_EFFORT[req.model or ""] and - not (type(req.reasoning_effort) == "string" and req.reasoning_effort ~= "") and - req.reasoning_effort == nil then - req.reasoning_effort = NEED_EFFORT[req.model] - end - if req.messages then - for _, msg in ipairs(req.messages) do - msg.reasoning_content = nil - if msg.tool_call_id ~= nil then - msg.tool_call_id = safe_tool_id(msg.tool_call_id) - end - if type(msg.tool_calls) == "table" then - for _, tc in ipairs(msg.tool_calls) do - if type(tc) == "table" and tc.id ~= nil then - tc.id = safe_tool_id(tc.id) - end - end - end - end - end - return json.encode(req) -end - --- aggregate_sse(raw):把上游 SSE(data: 行流)聚合成一个非流式 OpenAI 响应。 --- 强制 stream=true 后,内核的非流式路径会在这里收到完整 SSE 原文——必须聚合, --- 否则 json.decode 失败原样透传,Go 侧 unmarshal 报 invalid character 'd'。 --- 聚合 content / reasoning_content / tool_calls(按 index 拼接分片)/ --- finish_reason / usage;无可解析帧时返回 nil(调用方回退原样)。 -local function aggregate_sse(raw) - local content, reasoning = {}, {} - local finish, usage, id, model, created = nil, nil, nil, nil, nil - local tools_by_index = {} - local seen = false - for line in (raw .. "\n"):gmatch("([^\n]*)\n") do - local s = line:gsub("^%s+", ""):gsub("%s+$", "") - if s:sub(1, 5) == "data:" then - local payload = s:sub(6):gsub("^%s+", "") - if payload ~= "" and payload ~= "[DONE]" then - local ok, d = pcall(json.decode, payload) - if ok and type(d) == "table" then - seen = true - id = id or d.id - model = model or d.model - created = created or d.created - if type(d.usage) == "table" then usage = d.usage end - local ch = d.choices and d.choices[1] - if type(ch) == "table" then - if type(ch.finish_reason) == "string" and ch.finish_reason ~= "" then - finish = ch.finish_reason - end - local dl = ch.delta - if type(dl) == "table" then - if type(dl.content) == "string" then content[#content + 1] = dl.content end - if type(dl.reasoning_content) == "string" then - reasoning[#reasoning + 1] = dl.reasoning_content - end - if type(dl.tool_calls) == "table" then - for _, tc in ipairs(dl.tool_calls) do - if type(tc) == "table" and tc.index ~= nil then - local slot = tools_by_index[tc.index] - if slot == nil then - slot = { id = tc.id, type = tc.type or "function", - ["function"] = { name = "", arguments = "" } } - tools_by_index[tc.index] = slot - end - if tc.id then slot.id = tc.id end - local fn = tc["function"] - if type(fn) == "table" then - if fn.name then slot["function"].name = slot["function"].name .. fn.name end - if fn.arguments then slot["function"].arguments = slot["function"].arguments .. fn.arguments end - end - end - end - end - end - end - end - end - end - end - if not seen then return nil end - local msg = { role = "assistant", content = table.concat(content) } - local rc = table.concat(reasoning) - if rc ~= "" then msg.reasoning_content = rc end - local ordered = {} - for i = 0, 63 do - if tools_by_index[i] then ordered[#ordered + 1] = tools_by_index[i] end - end - if #ordered > 0 then msg.tool_calls = ordered end - local out = { - id = id or "", object = "chat.completion", created = created or 0, - model = model or "", - choices = { { index = 0, message = msg, - finish_reason = (finish and finish ~= "") and finish or "stop" } }, - } - if usage then out.usage = usage end - local ok2, enc = pcall(json.encode, out) - if not ok2 then return nil end - return enc -end - -function adapter.transform_response(raw_body) - -- 上游只会回流式(transform_request 强制 stream=true)。客户端要非流式时 - -- 内核把完整 SSE 原文交到这里,先聚合再走统一转换。 - if type(raw_body) == "string" - and (raw_body:match("^%s*data:") or raw_body:match("[\r\n]data:")) then - local aggregated = aggregate_sse(raw_body) - if aggregated ~= nil then raw_body = aggregated end - end - -- 非流式 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 type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then - -- Do not serialize an empty Lua table as JSON `{}`. That shape is - -- neither an OpenAI tool-call array nor an omitted field, and several - -- agent clients reject it on the final chunk. - for _, tc in ipairs(delta.tool_calls) do - if type(tc) == "table" and tc.id ~= nil then - tc.id = safe_tool_id(tc.id) - end - end - unified.tool_calls = delta.tool_calls - end - return json.encode(unified) -end - --- 错误收敛:标准 OpenAI 信封 {error:{message,...}} -function adapter.transform_error(status, body) - -- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/ - -- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。 - return __prelude.openai_error(status, body) -end - - --- ★ build_headers:buddy 要求客户端身份头 + 流式的 Accept。 --- --- 实测(2026-10-16):不发 `Accept: text/event-stream` 时上游接受请求(200) --- 但只回一帧空内容(completion_tokens=0),既不报错也不产出正文 —— 表现是 --- "模型不回答",极易误判成模型坏了或额度不足。 --- 参考 buddy-adapter.ts 的 send() 固定发该头。 --- --- 身份头照参考 credentialRequestHeaders + CODEBUDDY 产品配置: --- X-Domain 用产品域(不用凭据里的 domain 快照,那是登录时的区域, --- 跨产品迁移后会指错区);X-Product 是**部署类型**(SaaS);X-Product-Code --- 是产品码(codebuddy)。 -function adapter.build_headers(meta) - local h = { - ["Accept"] = "text/event-stream", - ["Content-Type"] = "application/json", - ["X-Domain"] = "copilot.tencent.com", - ["X-Product"] = "SaaS", - ["X-Product-Code"] = "codebuddy", - ["User-Agent"] = "CodeBuddyIDE/1.106.1", - } - -- Authorization 由内核在 buildHeaders 之后补(值为源的 api_key)—— - -- 这里显式给出同一份,保证与其它 buddy 请求形态一致。 - if type(meta) == "table" and type(meta.api_key) == "string" and meta.api_key ~= "" then - h["Authorization"] = "Bearer " .. meta.api_key - end - return h -end - -return adapter diff --git a/internal/lua/adapters/kimicode.lua b/internal/lua/adapters/kimicode.lua deleted file mode 100644 index 1509a1e..0000000 --- a/internal/lua/adapters/kimicode.lua +++ /dev/null @@ -1,79 +0,0 @@ -local adapter = {} - -adapter.name = "kimicode" -adapter.version = "1.0.0" -adapter.endpoint = "/v1/chat/completions" -adapter.headers = {} - --- KimiCode / Kimi K2 属于 OpenAI 兼容协议;但部分云端 API 会校验调用方 --- "app"(只放行特定 agent),要求每次请求带上按 secret 计算的应用签名。 --- 这里演示 build_headers 钩子:基于 timestamp + 请求体哈希生成签名头。 --- --- 配置要求(source.meta): --- meta: --- app_id: <申请到的 app id> --- app_key: <你的 key(由网关的 base_url 复用 api_key 亦可)> --- app_secret: <签名密钥> --- app_agent: code-agent # 若云端要求声明 agent 身份 -function adapter.transform_request(raw_body) - local ok, req = pcall(json.decode, raw_body) - if not ok then return raw_body end - req.model = req.model or "kimi-k2" - -- The gateway's private control fields are GONE from the body (v2): the - -- neutral `thinking` field carries that intent and each adapter maps it in - -- its own protocol terms. These upstreams have no thinking switch at all, - -- so the field is simply dropped here — which is what the nil-ing below - -- used to do, except the field being dropped is no longer the kernel's - -- private one. - req.thinking = nil - req.extra_body = nil - return json.encode(req) -end - --- 可选的动态签名钩子。meta 由 Go 注入: --- meta.url / meta.method / meta.body / meta.api_key / meta.timestamp / meta.source.meta -function adapter.build_headers(meta) - local h = { - ["Content-Type"] = "application/json", - ["X-App-Id"] = tostring((meta.source.meta or {}).app_id or ""), - ["X-Timestamp"] = tostring(meta.timestamp), - } - local agent = (meta.source.meta or {}).app_agent - if agent and agent ~= "" then - h["X-Agent"] = agent - end - -- 校验 app:通常要求 Authorization 用 app secret 派生签名 - local secret = (meta.source.meta or {}).app_secret - local api_key = meta.source.meta and meta.source.meta.api_key or meta.api_key - if secret and secret ~= "" then - local body_hash = sha256_hex(meta.body) - local sign_string = tostring(meta.timestamp) .. meta.method .. meta.url .. body_hash - local sign = hmac_sha256_hex(secret, sign_string) - h["Authorization"] = "Bearer " .. api_key - h["X-App-Sign"] = sign - else - h["Authorization"] = "Bearer " .. api_key - end - return h -end - -function adapter.transform_response(raw_body) - -- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github - -- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。 - return __prelude.openai_response(raw_body) -end - -function adapter.transform_stream_chunk(raw_chunk) - -- 标准 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) - -- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/ - -- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。 - return __prelude.openai_error(status, body) -end - -return adapter diff --git a/internal/lua/adapters/opencodezen.lua b/internal/lua/adapters/opencodezen.lua deleted file mode 100644 index c138600..0000000 --- a/internal/lua/adapters/opencodezen.lua +++ /dev/null @@ -1,586 +0,0 @@ -local adapter = {} - -adapter.name = "opencodezen" -adapter.version = "1.1.0" -adapter.endpoint = "/chat/completions" --- ★ hidden:不进用户可见适配器列表(v1.1.0)。本适配器是 OpenCode 免费层的 --- **门禁专用形态**(强制 stream、注入 read 桩工具、会话号服从倒计时公式), --- 直接暴露会被误当成通用 OpenAI 适配器选去接别的上游,结果不可预测。 --- 被 localzen 源引用时内核仍会把它列出来(源编辑器回显),其余场合隐藏。 -adapter.hidden = true - --- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1) --- --- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的 --- 服务,行为要求并不相同,因此各有专用适配器: --- --- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端 --- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in --- OpenCode"。 --- ★ reasoning_content:**保留**(与 Go 侧一致)。此前这里写「剥掉」是基于 --- 「免费池以非 thinking 模型为主、回传会报错」的假设,**生产实测已证伪** —— --- 剥掉恰恰会引发 --- The `reasoning_content` in the thinking mode must be passed back to the API --- (详见 transform_request 内的长注释:失败只在「上一轮真的产出了 reasoning」 --- 时才出现,故表现为同一会话里成功/失败交替,极难归因)。 --- 参考的共享序列化器(openai-compat.ts)也是「有就回传」。 --- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content, --- 否则 thinking 模式直接 400。 --- --- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端, --- 部分模型在带 tools 时上游直接失败(流式返回单 chunk --- "finish_reason":"network_error" 空 content,非流式 503 --- "Endpoint is unavailable",表现为"空回复")。 --- ============================================================================ --- ★ opencode Zen 免费层门禁(2026-10-15 实测解出,逐条对齐官方 CLI) --- --- 上游对免费池有**三个**必须同时满足的条件,缺任一则 403 FreeTierError --- ("free tier can only be used from within OpenCode"),与 UA / TLS 指纹 / 凭据来源 --- 均无关: --- --- 1. 端点必须是 /inference/v1/chat/completions(不是 /zen/v1) --- —— 由源的 base_url 决定,不在本适配器内。 --- 2. 必须同时发 x-opencode-session **与** x-opencode-session-id(值相同)。 --- 两个 id 的 12 位 hex 段由**确定公式**给出(见下),不是随机数; --- 时间容差实测 ≥ 60 秒,故 os.time() 的秒精度足够。 --- 3. 请求的 tools 里必须有一个名为 read 的工具,且其 parameters.properties --- **非空**。这是唯一的 tools 要求:实测抽掉官方 11 个工具中的任意一个均为 --- 200,**只有抽掉 read 变 403**;且 properties 里放任意属性名即可, --- 描述文字 / $schema / required 都不需要(properties 为空对象则 403)。 --- ⇒ 无 tools 的纯对话请求会被拒,故 transform_request 需兜底注入桩工具。 --- 4. **stream 必须为 true**:实测同为合法请求,stream=false 恒 403、stream=true 恒 200 --- (包括 max_tokens 20/60/200/1000/32000 均 200)。 --- ⇒ 非流式请求会被拒,故 transform_request 强制 stream=true。 --- --- 明确**不影响**通过的(均实测):system 内容(含无 system)、消息条数 --- (2/3/4/6 条均 200)、凭据(Bearer public 与普通 API key 均可)、 --- x-opencode-project / x-opencode-client / UA 里的 opencode/ 前缀。 --- --- ⚠️ 参考实现 dsh-our-free-model 的门禁桩(bash + read,但无真实 properties) --- 恰好卡在第 3 条上 —— 即它「伪装需跟进上游更新」的真实原因。 -adapter.headers = { - ["User-Agent"] = "opencode/1.19.4 ai-sdk/provider-utils/4.0.23 runtime/bun/1.4.4", -} - --- rand_id:按官方的 48-bit 公式生成 12 位 hex 段 + 14 位 base62 随机段。 --- --- 公式逆自官方 CLI 本地库(~/.local/share/opencode/opencode.db 的 session 表, --- 用 11/12 条真实 id 逐条比对完全一致): --- --- MASK = 2^48 - 1 --- K = 0x1afffffffffffe (注意不是 27<<48,两者差 2) --- session 段 = (K - now_ms * 4096) & MASK ← **倒计时式**:Δseg/Δms ≡ -4096 --- request 段 = (now_ms * 16) & MASK --- --- 为什么是「倒计时」而不是时间戳:0x1afffffffffffe 减去 ms*2^12,所以 seg 随 --- 时间**递减**。用 f"{ms:012x}" 之类的正向写法必然不对(我为此错过一轮)。 --- --- ⚠️ 精度:now_ms*4096 ≈ 7.3e15 < 2^53 ≈ 9.0e15,在 double 安全整数范围内, --- 故 LuaJIT 的默认数字类型(double)直接算即可,无需大整数库。 -local B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" -local MASK48 = 281474976710655 -- 2^48 - 1 -local ID_K = 0x1afffffffffffe - -local function rand_tail(n) - local out = {} - for i = 1, n do - local v = 0 - if math and math.random then - v = math.random(1, 62) - else - -- 无 math 时用时间做退化熵源:只要唯一,不要求密码学强度。 - v = (os.time() * 7 + i * 13) % 62 + 1 - end - out[i] = B62:sub(v, v) - end - return table.concat(out) -end - --- now_ms:毫秒。沙箱只有 os.time()(秒)与 os.clock()(CPU 时间,无用), --- 而上游时间容差实测 ≥ 60 秒,故秒精度足够;毫秒位补 0。 --- meta.timestamp 由内核给出(同为秒),优先用它以便同一请求内自洽。 -local function now_ms(meta) - local t = tonumber(meta and meta.timestamp) or (os.time and os.time()) or 0 - return t * 1000 -end - -local function rand_id(prefix, ms, kind, affinity) - local seg - if kind == "session" then - seg = (ID_K - ms * 4096) % (MASK48 + 1) - else - seg = (ms * 16) % (MASK48 + 1) - end - -- %012x:12 位十六进制,不足补零 - -- affinity(可空):会话亲和键。非空时其 8 位 hex 摘要混入 tail —— - -- 同一分钟桶内不同会话得到不同会号;hex 段不受影响(门禁只校验它)。 - local tail - if type(affinity) == "string" and affinity ~= "" then - tail = string.sub(sha256_hex(affinity), 1, 8) .. rand_tail(6) - else - tail = rand_tail(14) - end - return prefix .. string.format("%012x", seg) .. tail -end - --- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。 --- --- 为什么需要稳定:上游前缀缓存是会话级的(实测:同一段 6032 token 提示词, --- 固定会号第 2 次命中 5888;每请求换会号则恒为 0 命中)。而免费层门禁要求 --- x-opencode-session 的 12 位 hex 段服从「倒计时」公式(见 rand_id 注释), --- 所以不能真的「随便挑一个固定值」,只能选一个**时间桶**。 --- --- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中, --- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200), --- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。 --- ★ 会话亲和(v1.1.0,移植自 _local_archive 版本):整分钟桶让**所有请求** --- 共享会号,多会话并发时互相污染缓存键。改进:桶内再按**会话身份**分桶—— --- 1) meta.client_session(pi 等真会话客户端自带,会话恒定) --- 2) 退路:历史里第一条 user 消息做会话指纹(通用 OpenAI 客户端不发任何 --- 会话标识,实测抓包确认;第一条 user 消息在整个会话里恒定) --- 3) 再退:无 user 消息时退回纯时间桶(与 v1.0 行为一致) --- 实现:把会话身份 hash 进 14 位 base62 随机段(hex 段仍服从门禁公式)。 -local function conversation_fingerprint(body) - if type(body) ~= "string" or body == "" then return "" end - local ok, req = pcall(json.decode, body) - if not ok or type(req) ~= "table" then return "" end - for _, m in ipairs(req.messages or {}) do - if type(m) == "table" and m.role == "user" then - local c = m.content - if type(c) == "string" then - if c ~= "" then return c end - elseif type(c) == "table" then - for _, part in ipairs(c) do - if type(part) == "table" and part.type == "text" and part.text then - return part.text - end - end - end - return "" - end - end - return "" -end - -local function session_affinity_key(meta) - local cs = meta and meta.client_session - if type(cs) == "string" and cs ~= "" then return cs end - return conversation_fingerprint(meta and meta.body) -end - -local function session_bucket_ms(meta) - local ms = now_ms(meta) - return ms - (ms % 60000) -end - -function adapter.build_headers(meta) - local ms = now_ms(meta) - local bucket = session_bucket_ms(meta) - -- ★ 本函数的头集合以**官方 CLI 抓包**为准(opencode 1.19.4,2026-10-24 - -- 通过本地抓包代理取得明文请求)。官方实际发这五个 x-opencode-* 头: - -- - -- x-opencode-client: cli - -- x-opencode-project: global - -- x-opencode-request: msg_<12hex><14base62> - -- x-opencode-session: ses_<12hex><14base62> - -- x-opencode-session-id: ses_<12hex><14base62> ← 与 session 同值 - -- - -- ⚠⚠ 这里有一个**版本相关的陷阱**,用实测记录,因为它反反复复: - -- 同一台机器、同一个 `Bearer public` 凭据、同一个模型: - -- - -- CLI 1.18.29(只发 4 个头,无 session-id) → "Free usage limit reached" - -- CLI 1.19.4 (发 5 个头,含 session-id) → 200,任务真实完成 - -- - -- 我一度根据 1.18.29 的抓包删掉了 `x-opencode-session-id`,并把注释 - -- 改写成「官方根本不发这个头」。**那是错的** —— 旧版客户端当时的报错 - -- 本来就是额度不足,不是头缺失;而新版上游确实要这个头。删掉它会让 - -- 1.19.x 的指纹不匹配,重新落回 429。 - -- - -- 结论:头集合必须与 **UA 里声称的版本**一致。声称 1.19.4 就必须发 5 个头。 - -- 将来升级 UA 时,必须**重新抓包**该版本的发头集合,不能沿用旧结论。 - -- - -- 会话亲和键(可空)混入 tail:同一分钟桶内不同会话得到不同会号, - -- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。 - local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta)) - return { - ["User-Agent"] = adapter.headers["User-Agent"], - ["x-opencode-client"] = "cli", - -- project 固定:同网关实例共享一个工作区身份(官方也发同样值) - ["x-opencode-project"] = "global", - ["x-opencode-session"] = sid, - -- ★ 1.19.4 实测**必须**并发这一份(与 session 同值)。见上面的版本陷阱。 - ["x-opencode-session-id"] = sid, - -- request id 每请求唯一(不参与缓存键),用当前时间而非桶,避免同桶内重复 - ["x-opencode-request"] = rand_id("msg_", ms, "request"), - } -end - --- ============================================================================ --- ★ 免费层 tools 兜底:上游要求 tools 里必须有一个名为 read、且 --- parameters.properties 非空的工具(实测唯一要求;抽掉它即 403)。 --- 纯对话请求(客户端没传 tools)会被拒,故这里注入一个桩工具。 --- --- 只补缺,不改用户已有的 tools:用户自带 read 时原样透传(官方 CLI 就是这么发的), --- 用户带的其它工具保留不动。 -local FREE_GATE_TOOL = "read" -local function ensure_free_gate_tool(req) - local tools = req.tools - if type(tools) ~= "table" then tools = {} req.tools = tools end - for _, t in ipairs(tools) do - if type(t) == "table" then - local fn = t["function"] - -- 注意:function 是 Lua 关键字,必须用 req["function"] 形式取下标。 - if type(fn) == "table" and fn.name == FREE_GATE_TOOL then - local p = fn.parameters - -- 已有 read 但 properties 为空 ⇒ 补上;否则原样保留用户定义。 - if type(p) == "table" and type(p.properties) == "table" - and next(p.properties) ~= nil then - return - end - fn.parameters = { type = "object", - properties = { filePath = { type = "string", description = "Path to read" } } } - return - end - end - end - tools[#tools + 1] = { - type = "function", - ["function"] = { - name = FREE_GATE_TOOL, - description = "Read a file from the local filesystem.", - parameters = { type = "object", - properties = { filePath = { type = "string", description = "Path to read" } }, - required = { "filePath" } }, - }, - } -end - --- OpenAI /chat/completions format (pass-through, strip provider-specific fields) --- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part --- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息 --- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游 --- "unknown variant `image_url`, expected `text`");带工具调用的必须保留, --- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。 --- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder: --- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游 --- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。 -local ROLE_WHITELIST = { - system = true, - user = true, - assistant = true, - tool = true, - latest_reminder = true, -} - -function adapter.transform_request(raw_body) - local ok, req = pcall(json.decode, raw_body) - if not ok then return raw_body end - -- ★ 免费层 gate:tools 里必须有 read(且 parameters.properties 非空)。 - -- 放在最前面:它决定本请求会不会被 403,与后面的字段清理无关。 - -- 对所有模型都注入:免费池与付费池共用同一层形状检查,多一个桩工具无害 - -- (官方 CLI 本来也带全套工具),但缺了它会让免费模型整体失效。 - ensure_free_gate_tool(req) - -- ★ 免费层只接受流式:实测 stream=false 恒 403(同样 body 改 true 即 200)。 - -- 转成流式由上游以 SSE 回应,内核本就会聚合/透传流式响应,故对客户端透明。 - req.stream = true - req.disable_thinking = nil - req.extra_body = nil - -- stream_options is only valid alongside stream:true; sending it on a - -- non-streaming request is rejected by strict upstreams. - if req.stream then - if type(req.stream_options) ~= "table" then req.stream_options = {} end - req.stream_options.include_usage = true - end - if req.messages then - local kept = {} - for _, msg in ipairs(req.messages) do - if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then - msg.role = "system" - end - -- ★ 必须**保留** reasoning_content(不再剥离)。 - -- - -- 历史:这里原先写 `msg.reasoning_content = nil`,理由是「Zen 免费池 - -- 不接受回传」。**生产实测证伪**(2026-10-16): - -- provider localzen: api error 400: Upstream request failed: - -- [invalid_request_error] The `reasoning_content` in the thinking - -- mode must be passed back to the API. - -- 失败形态很隐蔽:**同一模型同一会话里成功与失败交替出现** —— - -- 因为只有当上一轮**真的产出了 reasoning**(thinking 模型、 - -- reasoning_effort 开启、长上下文)时,回放该历史才会被拒; - -- 剥掉之后,客户端看到的 502 与「上一轮想过」这件事毫无关联。 - -- 审计文件里能看到成串 502 紧跟在一个 prompt_tokens=139330 的 - -- 成功请求之后 —— 那正是「想了、但下一轮我们没把它还回去」。 - -- - -- 参考实现(`openai-compat.ts` 的 serializeMessages,opencode 适配器 - -- 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段: - -- `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}` - -- 即「有就回传」,没有额外条件 —— 与本适配器此前的假设方向相反。 - -- - -- ⚠️ 不改成「仅当 thinking 开启才回传」:我们**无法**从请求里可靠 - -- 判定上游是否处于 thinking 模式(真正知道的是上游)。而「有就回传」 - -- 是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200), - -- 需要时缺了就是硬 400。 - if type(msg.reasoning_content) == "string" and msg.reasoning_content == "" then - msg.reasoning_content = nil - end - local drop = false - if type(msg.content) == "table" then - local parts = {} - for _, part in ipairs(msg.content) do - if type(part) == "table" and part.type ~= nil and part.type ~= "text" then - -- multimodal part not supported by zen - else - table.insert(parts, part) - end - end - if #parts == 0 then - -- Content collapsed to nothing after stripping unsupported - -- parts. A message that still carries a tool call must - -- NEVER be dropped: the very next message is its tool - -- result, and dropping the call orphans that result. The - -- model then sees a result for a call it never made and - -- re-issues the same tool call on every turn (observed as - -- an infinite "repeated tool call" loop). - if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) - or msg.tool_call_id ~= nil then - msg.content = "" - else - drop = true - end - else - msg.content = parts - end - end - if not drop then - table.insert(kept, msg) - end - end - req.messages = kept - end - return json.encode(req) -end - --- ★ aggregate_sse:把 SSE 流(openai chat.completion.chunk 形状)聚合成一个非流式 --- 响应对象,供 transform_response 在「客户端要非流式、上游却回流式」时使用。 --- 返回 JSON 字符串;无法聚合(无任何 chunk)时返回 nil。 --- --- 只处理 content / reasoning_content / finish_reason / usage 四类增量: --- 它们覆盖了内核需要从响应里取的全部字段(正文、结束原因、token 计数)。 -local function aggregate_sse(raw) - local content, reasoning, finish = {}, {}, "" - local usage, id, model, created = nil, nil, nil, nil - local seen = false - for line in (raw .. "\n"):gmatch("([^\n]*)\n") do - local s = line:gsub("^%s+", ""):gsub("%s+$", "") - if s:sub(1, 5) == "data:" then - local payload = s:sub(6):gsub("^%s+", "") - if payload ~= "" and payload ~= "[DONE]" then - local ok, d = pcall(json.decode, payload) - if ok and type(d) == "table" then - seen = true - id = id or d.id - model = model or d.model - created = created or d.created - if type(d.usage) == "table" then usage = d.usage end - local ch = d.choices and d.choices[1] - if type(ch) == "table" then - if ch.finish_reason and ch.finish_reason ~= "" then - finish = ch.finish_reason - end - local dl = ch.delta - if type(dl) == "table" then - if type(dl.content) == "string" then content[#content + 1] = dl.content end - if type(dl.reasoning_content) == "string" then - reasoning[#reasoning + 1] = dl.reasoning_content - end - end - end - end - end - end - end - if not seen then return nil end - local msg = { role = "assistant", content = table.concat(content) } - local rc = table.concat(reasoning) - if rc ~= "" then msg.reasoning_content = rc end - local out = { - id = id or "", object = "chat.completion", created = created or 0, model = model or "", - choices = { { index = 0, message = msg, finish_reason = finish ~= "" and finish or "stop" } }, - } - if usage then out.usage = usage end - local ok2, enc = pcall(json.encode, out) - if not ok2 then return nil end - return enc -end - -function adapter.transform_response(raw_body) - -- ★ 免费层只接受 stream=true(见文件头门禁第 4 条),所以本适配器把每个请求 - -- 都改成了流式 —— 但内核是按**客户端意图**选路径的:客户端发非流式时, - -- 内核走 Provider.Chat 并调本函数(期望 JSON),而上游回的是 SSE。 - -- 若在这里直接 json.decode 就会得到 "invalid character 'd'" 错误。 - -- - -- 因此本函数必须先识别 SSE 并自行聚合,再走原来的 JSON 路径。 - -- 这样保持内核对上游协议无感(内核不知道也不该知道上游选了流式)。 - if type(raw_body) == "string" and (raw_body:sub(1, 6) == "data: " or raw_body:find("\ndata: ", 1, true)) then - local aggregated = aggregate_sse(raw_body) - if aggregated ~= nil then - local ok2, resp2 = pcall(json.decode, aggregated) - if ok2 and type(resp2) == "table" then raw_body = aggregated end - end - end - local ok, resp = pcall(json.decode, raw_body) - if not ok or resp == nil then return raw_body end - - local unified = { - content = "", - finish_reason = "", - token_usage = { prompt = 0, completion = 0, total = 0 } - } - - if type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 - local hit = 0 - if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then - hit = resp.usage.prompt_tokens_details.cached_tokens - unified.token_usage.prompt_tokens_details = { cached_tokens = hit } - end - if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then - unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens - unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 - if hit == 0 then - unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } - end - end - -- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断 - -- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。 - local ctd = resp.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - -- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。 - -- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。 - -- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。 - if resp.cost ~= nil then - unified.cost = tostring(resp.cost) - end - - if type(resp.choices) == "table" and #resp.choices > 0 then - local ch = resp.choices[1] - if type(ch.message) == "table" then - unified.content = ch.message.content or "" - local reasoning = ch.message.reasoning_content or ch.message.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if type(ch.message.tool_calls) == "table" then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - unified.finish_reason = ch.finish_reason or "" - end - - return json.encode(unified) -end - -function adapter.transform_stream_chunk(raw_chunk) - local ok, chunk = pcall(json.decode, raw_chunk) - if not ok then return "" end - - -- OpenAI-style streams may attach usage to a chunk with empty choices - -- (the final usage chunk). Preserve it; the gateway emits it as the - -- terminal usage chunk. Note: keys must match Go's TokenUsage json tags - -- (prompt/completion/total); the gateway re-emits standard *_tokens. - local uses = nil - if type(chunk.usage) == "table" then - uses = { - prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, - completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, - total = chunk.usage.total_tokens or chunk.usage.total or 0, - } - -- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif: - -- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让 - -- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致 - -- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。 - if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - end - if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then - uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens - uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - if uses.prompt_tokens_details == nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } - end - end - local ctd = chunk.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - if not chunk.choices or #chunk.choices == 0 then - -- 空 choices 的帧既不是内容也不是结束信号。上游发两种: - -- {"choices":[],"usage":{...}} —— 终帧用量 - -- {"choices":[],"cost":"0"} —— 单独的费用帧 - -- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。 - local out = { done = false } - if uses ~= nil then out.usage = uses end - if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end - if uses == nil and chunk.cost == nil then return "" end - return json.encode(out) - end - local delta = chunk.choices[1].delta or {} - local fr = chunk.choices[1].finish_reason - - local finish = (type(fr) == "string" and fr ~= "") and fr or nil - - local unified = { - content = delta.content or "", - done = (finish ~= nil) - } - if finish then - unified.finish_reason = finish - end - if uses ~= nil then - unified.usage = uses - end - -- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content) - local reasoning = delta.reasoning_content or delta.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if delta.tool_calls then - -- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments - unified.tool_calls = delta.tool_calls - end - return json.encode(unified) -end - --- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}}; --- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。 -function adapter.transform_error(status, body) - local ok, resp = pcall(json.decode, body) - if not ok or type(resp) ~= "table" then return nil end - local e = resp.error - if type(e) ~= "table" then return nil end - if e.type == "FreeUsageLimitError" then - return "zen free pool quota exhausted" - end - return e.message -end - -return adapter diff --git a/internal/lua/adapters/trae.lua b/internal/lua/adapters/trae.lua deleted file mode 100644 index e9759ca..0000000 --- a/internal/lua/adapters/trae.lua +++ /dev/null @@ -1,292 +0,0 @@ --- trae 源适配器(trae-local-api 的 OpenAI 兼容代理) --- trae-local-api 运行在 http://127.0.0.1:19900,接 Trae CN 账号的云资源。 --- 协议:OpenAI /v1/chat/completions --- 特性: --- - 排队:上游 busy 时排队,可能长时间无响应 --- - 非流式请求偶发返回 SSE 数据(upstream bug) --- - 支持 thinking,由上游模型自动决定是否开启 - -local adapter = {} -adapter.name = "trae" -adapter.version = "1.0.0" -adapter.endpoint = "/v1/chat/completions" -adapter.headers = {} - -function adapter.transform_request(raw_body) - local ok, req = pcall(json.decode, raw_body) - if not ok then return raw_body end - -- The gateway's private control fields are GONE from the body (v2): the - -- neutral `thinking` field carries that intent and each adapter maps it in - -- its own protocol terms. These upstreams have no thinking switch at all, - -- so the field is simply dropped here — which is what the nil-ing below - -- used to do, except the field being dropped is no longer the kernel's - -- private one. - req.thinking = nil - req.extra_body = nil - if req.messages then - for _, msg in ipairs(req.messages) do - msg.reasoning_content = nil - end - end - return json.encode(req) -end - --- parse_text_tool_calls extracts tool calls that an upstream emitted as PLAIN --- TEXT instead of using the OpenAI tool_calls field. --- --- trae-local-api's OpenAI endpoint (/v1/chat/completions) does not read the --- request's `tools` array at all, so the relayed model is never told the tool --- schema; it falls back to printing --- --- {"name": "get_weather", "arguments": {"location": "北京"}} --- --- into message.content, leaves message.tool_calls null, and reports --- finish_reason="stop". A client following the OpenAI contract therefore never --- sees a tool call: the agent loop terminates unexpectedly mid-conversation --- (and a hand-written replay produces an empty function name next turn). --- --- Both tag spellings are accepted: the same codebase's Anthropic endpoint --- instructs models to emit , and models mix the two. Key names vary --- too (arguments / params / input), so all are tried. --- --- Returns (tool_calls_array_or_nil, content_with_blocks_removed). -local function parse_text_tool_calls(content) - if type(content) ~= "string" or content == "" then return nil, content end - - local tcs = {} - local idx = 0 - - local function collect(pattern) - for payload in content:gmatch(pattern) do - local ok, obj = pcall(json.decode, payload) - if ok and type(obj) == "table" then - -- some models wrap it as {"function":{"name":..,"arguments":..}} - local fn = obj["function"] - local name = obj.name or (type(fn) == "table" and fn.name) or nil - if name then - local args = obj.arguments or obj.params or obj.input - if args == nil and type(fn) == "table" then - args = fn.arguments or fn.params - end - if type(args) == "string" then - local aok, decoded = pcall(json.decode, args) - args = aok and decoded or {} - elseif type(args) ~= "table" then - args = {} - end - idx = idx + 1 - table.insert(tcs, { - id = obj.id or ("call_text_" .. idx), - type = "function", - name = name, - arguments = args - }) - end - end - end - end - - -- `` allows attributes; %s* handles the "" spacing - -- that trae-local-api's own prompt example uses. - collect("]*>%s*(.-)%s*") - collect("]*>%s*(.-)%s*") - - -- Legacy / mimicked form: models that saw past assistant turns folded as - -- [Called tool: name({...})] - -- tend to emit the same shape instead of a real block. Recover it so - -- the agent loop keeps working; otherwise the client receives plain text - -- where a structured tool call should be. Capture the name and the JSON - -- object literal independently (the JSON is the only {...} run here). - if #tcs == 0 and content:find("%[Called tool") then - for name, argsraw in content:gmatch("%[Called tool%s*:%s*([%w_%-%.]+)%s*%((%b{})%s*%)%s*%]") do - local aok, decoded = pcall(json.decode, argsraw) - idx = idx + 1 - table.insert(tcs, { - id = "call_legacy_" .. idx, - type = "function", - name = name, - arguments = aok and decoded or {} - }) - end - end - - if #tcs == 0 then return nil, content end - - -- drop the blocks from user-visible content; keep any surrounding prose - local stripped = content:gsub("]*>%s*.-%s*", "") - stripped = stripped:gsub("]*>%s*.-%s*", "") - stripped = stripped:gsub("%[Called tool%s*:%s*[%w_%-%.]+%s*%(%b{}%s*%)%s*%]", "") - stripped = stripped:gsub("^%s+", ""):gsub("%s+$", "") - return tcs, stripped -end - -function adapter.transform_response(raw_body) - -- trae-local-api 偶发在非流式请求中返回 SSE 格式数据, - -- 表现为多个 data: {...} 行或混合了 reasoning_chunk 等。 - -- 剥掉 data: 前缀,取最后一个完整的 JSON 块(通常是 - -- 最终的 stop chunk 或 usage chunk)。 - local use_json = raw_body - if raw_body:match("^data:") or raw_body:match("\ndata:") then - -- 取最后一个 data: 行的 JSON - local last = nil - for line in raw_body:gmatch("data: ([^\n\r]+)") do - local trimmed = line:match("^%s*(.-)%s*$") - if trimmed and trimmed ~= "[DONE]" then - local ok2, obj = pcall(json.decode, trimmed) - if ok2 and type(obj) == "table" then - last = obj - end - end - end - if last then - use_json = json.encode(last) - end - -- 如果解析失败,回退到原始 body - end - - local ok, resp = pcall(json.decode, use_json) - 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 - 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" and #ch.message.tool_calls > 0 then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local fn = tc["function"] or {} - local args = fn.arguments - if type(args) == "string" then - local args_ok, decoded = pcall(json.decode, args) - args = args_ok and decoded or {} - elseif type(args) ~= "table" then - args = {} - end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = fn.name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - unified.finish_reason = ch.finish_reason or "" - - -- Fallback: trae-local-api's OpenAI endpoint drops the request's - -- `tools` array entirely, so the relayed model is never told the tool - -- schema and instead PRINTS a {...} block into - -- content, leaving message.tool_calls null and finish_reason="stop". - -- A client following the OpenAI contract then sees a normal completion - -- and its agent loop terminates mid-conversation. Recover the - -- structured call so the loop can continue. - if unified.tool_calls == nil then - local recovered, cleaned = parse_text_tool_calls(unified.content) - if recovered then - unified.tool_calls = recovered - unified.content = cleaned - unified.finish_reason = "tool_calls" - end - end - end - - return json.encode(unified) -end - -function adapter.transform_stream_chunk(raw_chunk) - local ok, chunk = pcall(json.decode, raw_chunk) - if not ok then return "" end - - 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 - 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 type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then - unified.tool_calls = delta.tool_calls - end - return json.encode(unified) -end - -function adapter.transform_error(status, body) - local ok, resp = pcall(json.decode, body) - if not ok or type(resp) ~= "table" then - -- Non-JSON body: extract title from HTML or first line - local title = string.match(body or "", "(.-)") - 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 - -- trae quota / rate limit annotations - if e.code then - return tostring(e.code) .. ": " .. e.message - end - 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 - return nil -end - -return adapter diff --git a/internal/lua/channels_gemini_adapter_test.go b/internal/lua/channels_gemini_adapter_test.go deleted file mode 100644 index 87ae56d..0000000 --- a/internal/lua/channels_gemini_adapter_test.go +++ /dev/null @@ -1,164 +0,0 @@ -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 := ReadBundledPlugin("channels") - 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 deleted file mode 100644 index 5ba3ce5..0000000 --- a/internal/lua/channels_trae_solo_test.go +++ /dev/null @@ -1,102 +0,0 @@ -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 := ReadBundledPlugin("channels") - 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/plugin_icons_test.go b/internal/lua/plugin_icons_test.go index eebafc0..f75017e 100644 --- a/internal/lua/plugin_icons_test.go +++ b/internal/lua/plugin_icons_test.go @@ -50,14 +50,12 @@ func pageIcons(t *testing.T, path string) []string { // 这是一份"必须被覆盖的清单",漏登记会让新插件逃避判据。 var pagePlugins = []string{ "plugins/billing.lua", - "plugins/channels.lua", - "plugins/quota.lua", } // TestEveryPagePluginDeclaresSVGIcon 钉住"每个带页面的插件都有 inline SVG 图标"。 // // 两个必查项,各自对应一种真实缺陷: -// - 缺 icon / 空串 ⇒ 侧边栏空白(channels 的实测症状) +// - 缺 icon / 空串 ⇒ 侧边栏空白(实测症状) // - emoji ⇒ 尺寸不跟原生 tab、忽略主题色(billing 的历史缺陷) func TestEveryPagePluginDeclaresSVGIcon(t *testing.T) { for _, p := range pagePlugins { diff --git a/internal/lua/plugin_shard_config_test.go b/internal/lua/plugin_shard_config_test.go index 20dfa11..98b2faa 100644 --- a/internal/lua/plugin_shard_config_test.go +++ b/internal/lua/plugin_shard_config_test.go @@ -3,7 +3,6 @@ package lua import ( "os" "path/filepath" - "strings" "testing" ) @@ -159,18 +158,3 @@ func TestShardConfigSeedingDoesNotAliasSharedState(t *testing.T) { t.Fatalf("the shared configuration changed to %q after a shard read", got) } } - -// TestQuotaProbesThroughTheFanoutPath is the reason the above matters. If the -// quota page stopped probing through fanout, seeding shards would be dead code -// and the asymmetry could return unnoticed. -func TestQuotaProbesThroughTheFanoutPath(t *testing.T) { - src := readShippedPlugin(t, "quota.lua") - if !strings.Contains(src, "pluginAPI.fanout(") { - t.Fatal("quota.lua no longer probes through the fanout (shard) path — the " + - "page and the tests would then exercise different code, which is how " + - "the missing shard configuration stayed hidden") - } - if !strings.Contains(src, "memo_record") { - t.Fatal("quota.lua no longer records through memo_record") - } -} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua deleted file mode 100644 index 8556b90..0000000 --- a/internal/lua/plugins/channels.lua +++ /dev/null @@ -1,13612 +0,0 @@ --- channels:多平台账号接入(登录认证 → 凭据 → 自动领取额度 → 注册为上游源) --- --- 为什么需要它 --- ------------ --- 网关的上游源(sources)一直是**运维手工配置**的:填 base_url、填 api_key。 --- 而现实里有大量平台是"注册个账号、每天领免费额度"的形态,它们的接入方式是 --- **登录**而不是"申请一个 key"。这类平台要接入网关,就需要:驱动登录 → 拿到 --- 凭据 → 定时领取额度 → 把端点当源用。 --- --- 本插件把这套流程做成通用的:一个插件内建平台表,每个平台声明自己的 --- 登录方式(link-poll / callback / paste)、额度领取接口、上游端点形状。 --- 新增一个平台是往 PLATFORMS 里加一条,不改内核。 --- --- 设计边界(与内核的约定) --- ------------------------ --- 内核提供基础设施,插件自己不实现这些: --- --- modelrouter.http 受出站白名单约束的 HTTP(登录/领额度都走它) --- modelrouter.secrets 凭据保险库(0600、独立文件、跨插件不可读) --- modelrouter.kernel 内核动作面(add_source / probe_all / reset_health…) --- --- 本插件负责"平台知识":每个平台的登录地址、额度接口、认证头形状、模型清单。 --- 平台知识放插件而不是内核,是因为它变化快、且属于"某个厂商的具体行为"—— --- 内核里出现厂商名就是分层被破坏了。登录会话、服务端浏览器栈也全归插件自持。 --- --- 权限(本插件需要,全部可在设置页看到"申请中") --- ------------------------------------------------ --- add_source 把平台端点注册为上游源 --- list_sources 显示当前已接入的平台 --- probe_all 接入后立刻验证可达 --- reset_health 上游恢复后清冷却 --- status 面板显示运行状态 --- --- 额外出站主机需批准(plugin.hosts):登录页所在的域名不是上游源,默认会被 --- 出站白名单拒绝——这是刻意的,声明是请求、批准才是授权。 --- --- 登录会话归本插件自持(内核无"登录"概念):会话表在 plugin.state.sessions, --- 带 nonce 与过期时间;浏览器栈(Xvfb/chromium/x11vnc/websockify)由本插件用 --- os.execute 直接拉起并记录 pid,插件卸载/会话结束时自己回收。 --- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化流程(轮询/回调) --- → ③ VNC 无头浏览器兜底(无浏览器系统 / 自动化也无法完成的场景)。 - -local plugin = { - name = "channels", - version = "1.0.0", - description = "多平台账号接入:登录认证、凭据保管、自动领取额度、注册为上游源", - author = "modelrouter", -} - --- ★★ 顶层常量集中到 plugin.K:本文件主 chunk 的 local 数已**顶到 Lua 上限 200**, --- 再加一个顶层 local 就编译失败(实测 +1 即 `main function has more than 200 --- local variables`),而且炸的是**整个渠道插件**(插件加载失败 = 全渠道不可用)。 --- 挂表字段**不占** local 槽位,故把纯常量收进这里腾出余量。 --- --- ⚠️ 只搬**纯常量**(字符串/数字/自闭合字面量):它们没有前向引用问题, --- 搬迁是纯机械的。函数保持 local(它们之间的调用顺序是真实依赖)。 -plugin.K = { - CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com", - CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect", - HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html", - CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains", - CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3", - QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000, - QODER_REFRESH_HOUR_UTC8 = 10, - QODER_MACHINE_ENVIRONMENT = "3", - STORE_NAME = ".channels-store.json", - B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", - B64_LOOKUP = {}, - SESSION_TTL = 900, -- 15 分钟,过期会话在读取时惰性清理 - MODELS_DEV_URL = "https://models.dev/api.json", - MODELS_DEV_TTL = 6 * 3600, -- 6 小时(照参考的 TTL 量级) - EXPIRING_WINDOW_DAYS_DEFAULT = 15, - BALANCE_CACHE_TTL = 60, - DEAD_MODEL_TTL_DAYS = 30, - REMOTE_CATALOG_COOLDOWN = 30, -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS - INJECT_PLATFORM_LIMIT = 32, - ZCODE_ORIGIN = "https://zcode.z.ai", - -- ★ 双区端点表(2026-10-19):buddy/WorkBuddy 与 qoder 国际/国内两份 host。 - -- 放 plugin.K 而非顶层 local —— 本文件主 chunk 的 local 数已顶到 Lua 上限 - -- 200(TestChannelsLuaHasLocalHeadroom 钉着),新增常量必须走这里。 - -- qoder: intl=openapi.qoder.sh,cn=openapi.qoder.com.cn(CN 无公开 chat - -- 端点,只做登录/领取);CN 的 client_id 与授权页都不同 - -- (照参考 qoder-product.ts QODER_CN:qoder.cn / 732aef47-…)。 - -- buddy: cn=copilot.tencent.com(platform=ide),intl=www.workbuddy.ai - -- (platform=workbuddy-ai,productCode=workbuddy,照参考 product.ts WORKBUDDY)。 - QODER_REGION_BASE = { - intl = "https://openapi.qoder.sh", - cn = "https://openapi.qoder.com.cn", - }, - QODER_REGION_AUTH = { - intl = { auth = "https://qoder.com", client_id = "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb" }, - cn = { auth = "https://qoder.cn", client_id = "732aef47-9cf2-46a2-95fe-4cebb5d0d1fa" }, - }, - BUDDY_REGION_HOST = { - intl = "www.workbuddy.ai", - cn = "copilot.tencent.com", - }, - GEMINI_DEFAULT_PROJECT = "aicode-consumers", - GEMINI_UPSTREAM_USER_AGENT = "antigravity", - CLI_PREFIX = "You are ZCode, an interactive coding agent", - QODER_QUEUE_CODE = "10605", -- 10605=排队(重试),与 110(额度耗尽)语义相反 -} - --- ★★ plugin.adaptersDir():适配器目录 = 插件目录的**兄弟**目录 --- (/plugins 与 /adapters)。由插件目录推导,不猜绝对路径 --- (测试与多实例部署都靠它隔离)。 --- --- ⚠️ 为什么必须放在**插件主体**:适配器运行在**另一个沙箱 VM**, --- 那里 `modelrouter.plugin_dir` 与环境变量 **都不存在**、 --- **也没有文件系统访问** ⇒ 在适配器里读任何文件都拿不到东西且**静默降级** --- (实测:zcode 适配器因此只拿到自己定义的 2 个函数,缺 7 个, --- 每次调用内核报 "missing transform_response" ⇒ 渠道完全不可用)。 --- 所以凡是要"读兄弟文件"的,一律在插件主体做好再内联进去。 -function plugin.adaptersDir() - local pd = modelrouter.plugin_dir - if type(pd) ~= "string" or pd == "" then - pd = (os.getenv and (os.getenv("MODELROUTER_PLUGIN_DIR") or os.getenv("LLMSPROXY_PLUGIN_DIR"))) or "" - end - if type(pd) ~= "string" or pd == "" then return nil end - local base = pd:gsub("/+$", "") - local parent = base:match("^(.*)/[^/]+$") - if not parent then return nil end - return parent .. "/adapters" -end - --- 申请的内核权限。设置页会把"申请中但未授权"标红。 --- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。 --- --- ★ 这份清单必须与实际调用**逐一对齐**:`remove_source` 曾被漏声明, --- 而插件在 5 处调用它(syncAccountSource 的重建、dropAccountSource、 --- account_delete 收尾、两处清旧单账号源)⇒ 生产只授了 add_source, --- 于是**撤源全部被拒**。更坏的是它**静默**:AddSource 是**同名 upsert**, --- 所以"先 remove 再 add"的 remove 失败看不出来,只有**纯删除路径** --- (删账号/停用)才暴露 —— 表现是「账号删了,它的源还在对外服务」。 --- ⇒ 加动作时**同时**改这里;判据见 channels_kernel_declaration_test.go。 -plugin.kernel = { - "add_source", "remove_source", "list_sources", - "probe_all", "reset_health", "status", -} - --- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 --- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "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_pool_reason", "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", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__trae_catalog_probe", "__trae_headers_probe", "__platforms_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__chosen_account_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_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" } - - - --- ============================================================================ --- 平台表 --- ============================================================================ --- --- 每个平台声明登录方式与各端点。字段: --- 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)。 --- 仅在运维自己的网关、自己的账号范围内使用;法律风险运维自负(已确认)。 --- 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 - --- ★ 选号链路的前向声明(第 11 次踩同一个坑) --- pickAccountForSource 定义在 syncProviderSource 旁边(~4160),但它依赖 --- accountBalanceRaw / balanceTier(~6690/6716)。不声明的话那两个名字在 --- 定义处会被解析成**全局**(nil),选号时抛 --- `attempt to call global 'accountBalanceRaw' (a nil value)` —— account_add --- 直接 500,源根本注册不出来(已实测踩过)。 -local pickAccountForSource, accountBalanceRaw, balanceTier - --- ★★ 这批前向声明必须在 PLATFORMS **之前**:平台表里的 headers_fn / claim_fn --- 闭包会在**运行时**调用这些函数,而闭包捕获的是**词法作用域里的那个变量**。 --- 若声明写在后面(例如 line ~5479),PLATFORMS 段里的引用会被解析成**全局** --- `traeCheckinHeaders`(nil),而后面的 `local` 是**另一个变量**,赋不到它 --- ⇒ 调用点永远拿到 nil。 --- --- 真实缺陷(2026-10-17 用户报「显示额度不可用」): --- trae 的 checkin_status.headers_fn 调 traeCheckinHeaders(...) 返回 nil, --- fetchAccountJSON 于是退回默认 `Authorization: Bearer ` --- ⇒ 上游回「not able to authenticate you」;而同一账号的**签到领取是成功的** --- (claim_fn 那条路同样拿到 nil,但它内部有 Bearer 兜底且 claim 端点接受)。 --- ⇒ 教训:Lua 的 local 只对**其后的**代码可见;闭包不是延迟绑定的魔法。 -local traeCheckinHeaders, traeFixNickname, traeIdeHeaders -local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId - -local PLATFORMS = { - -- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)===== - -- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的 - -- 注意:占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接 - -- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。 - -- 夹具由测试经 channels/inject_platforms 端点运行时注入。 - - -- TRAE(字节跳动)—— callback 登录 + ExchangeToken 续期 + 每日签到。 - -- 这是最标准的一条,作为"同类厂商"的实现模板。 - { - id = "trae", - name = "TRAE(字节)", - vendor = "bytedance", - flow = "callback", - -- 授权页(照参考 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" }, - -- ★ 远端模型目录:POST /api/ide/v1/batch_get_detail_param(通过 models_remote)。 - -- - -- 为什么要有它:**只有这个端点能告诉我们每个模型的真实元数据** —— - -- 上下文窗口、输出上限、是否支持图像/工具响应图像、以及**思考档位**。 - -- 硬编码的 models 兜底表只有名字,于是 UI 上所有模型都显示"未提供推理等级", - -- 且多模态能力无从得知。 - -- - -- ⚠ 端点选择:**必须用 batch**(多通道)而不是单通道 get_detail_param。 - -- 真实 CN IDE 用 batch 一次查 22 个 function,每个 function 各自一套目录; - -- 只传聊天通道会让非聊天通道的条目在响应里**位置错乱甚至被跳过**(参考实测)。 - -- - -- ⚠ 请求体必须传**全部 22 个 function**(与真实 IDE 一致),不是只传我们关心的 - -- 那几个 —— 理由同上:响应分组与请求顺序相关。解析侧再用白名单做**准入**。 - models_remote = { - endpoints = { { - url = "https://trae-api-cn.mchost.guru/api/ide/v1/batch_get_detail_param", - method = "POST", - body = '{"functions":["ui_builder_v2","solo_coder","chat_v3","solo_builder",' - .. '"builder_v3","builder","chat","inline_chat","git_ai",' - .. '"custom_agent_generation","utils","code_reviewer","code_review_summary",' - .. '"solo_agent","solo_agent_remote","solo_work_remote","solo_agent_lite",' - .. '"solo_work_lite","solo_design_lite","solo_design_remote","multimodal",' - .. '"system_diagnosis"],"agent_type":"",' - .. '"current_config_info":{"config_name":"","is_custom_model":false},' - .. '"mode_type":0,"access_type":0,"ab_force_vids":"",' - .. '"ab_autotest_advanced_mode":0,"show_custom_model":true}', - -- ★ 鉴权方案是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的 - -- 设备身份),**不能只发 Bearer** —— 参考注释点明简化头实测失败。 - -- 走 sign="trae_solo"(复用平台既有的 traeCheckinHeaders 实现, - -- 与 codearts 的 ep.sign 同一模式,不另写一套头部构造)。 - sign = "trae_solo", - } }, - auth = "none", -- 鉴权头由 headers_fn 负责 - parse = "trae_batch", - -- ★★ 只收**适配器真正会发的那条通道**里的模型。 - -- - -- 实测(2026-10-20,真实目录 22 个 function / 93 条去重模型): - -- 同一个 config_name 在不同 function 里的**可用性不同**,而本插件的 - -- trae 适配器只会发 `solo_work_lite`(见 TRAE_FUNCTION)。把别的通道 - -- 的模型也列出来,用户选中后上游要么回**空内容**(kimi-k2.8-preview / - -- deepseek-V3 / glm-4.7 / code-review-judge 实测皆空),要么**悄悄回落到 - -- 默认模型**(custom_model_gpt-5 自称 GPT-4、sagitta 自称 Claude —— - -- 这正是"模型不正确"的真实来源)。 - -- - -- 收窄到 solo_work_lite 后:20 个模型,逐个实测都能返回内容且确实是 - -- 它自称的那个模型(glm-5.3 / kimi-k3 / minimax-m3.1-flash / qwen3.8-max / - -- step-5-preview / deepseek-v4.1-flash …)。 - admit_functions = { "solo_work_lite" }, - }, - -- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat, --- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。 - base_url = "https://trae-api-cn.mchost.guru", - -- TRAE 非 OpenAI 兼容,用**本插件自装**的 SOLO 适配器 - -- (见 plugin.K.TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。 - adapter = "channels-trae", - -- ★ 让 syncAccountSource 把凭据里的设备身份放进源的 meta —— - -- trae 的推理头需要 X-Uid / X-Device-Id / X-Machine-Id 派生, - -- 而适配器沙箱里读不到凭据(只能用内核传进来的 meta)。 - source_meta_from_credential = true, - -- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden - -- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。 - models = { - "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_* 是 TRAE 的「自定义模型槽位」,**不是可调用的模型**: - -- 上游目录里它们的判据是 `is_custom_model: true`(本插件的 trae_batch - -- 解析器早就按这条**准入**剔除),但兜底表此前把它们当普通模型列出来 —— - -- 于是远端目录取不到时(企业租户账号实测:22 个 function 全回 - -- `2001 Config detail ... not found` ⇒ 目录为空),用户在模型选择器里 - -- 能看到这些条目,选中后上游回空内容(实测 custom_model_kimi / - -- custom_model_claude / custom_model_1M 均返回空 content)—— - -- 表现就是「模型不正确 / 渠道不可用」。 - -- 它们与内部子 agent 同性质:保留在目录里(排查可见),但不进可选列表。 - -- ★ 远端目录一旦可用,就**不再补兜底表**(参考语义:remote ?? fallback)。 - -- 兜底表里的名字是历史快照,其中有若干**上游已不存在**的条目 - -- (实测 sagitta 自称 Claude、aquila 自称 ChatGPT、glm-5 / DeepSeek-V4-Flash - -- 在 solo_work_lite 通道里根本没有配置)—— 补进来 = 给用户一堆"点了回错模型" - -- 的选项。仅在远端目录为空时才用兜底表(那才是它的用途)。 - models_fallback_no_supplement = true, - models_hidden = { - "browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary", - }, - models_unavailable = { - "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", - }, - -- 每日签到: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 = { - url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/status", - method = "POST", body = "{}", - -- ★ 鉴权方案与 checkin 相同:Cloud-IDE-JWT + 设备身份头。 - -- 参考 trae.ts 端点表:签到状态 / 积分余额 皆 Cloud-IDE-JWT。 - -- headers_fn 在**文件末尾的绑定循环**里接上(auth="cloud-ide-jwt" 段): - -- 内联定义会因作用域顺序拿不到 traeCheckinHeaders / platformById, - -- 闭包捕获 nil、错误被 pcall 吞掉 ⇒ **静默退回 Bearer** ⇒ - -- 上游「not able to authenticate you」⇒ 用户看到「额度不可用」。2026-10-17。 - status_fn = function(rec) - return { - active = rec.enable == true, - todayCheckedIn = rec.checked_in == true, - dailyCredit = tonumber(rec.credits) or 0, - } - end, - }, - -- 余额(对齐参考:POST /trae/api/v2/pay/ide_user_ent_usage → remain = Σ(credits_limit - credits_amount)) - balance = { - url = "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage", - method = "POST", body = '{"require_usage":true,"req_source":2}', - -- 同上:积分余额同为 Cloud-IDE-JWT;headers_fn 在末尾绑定循环里接上。 - -- ★★ 字段在**两层嵌套**里,不在 pack 顶层: - -- pack.entitlement_base_info.quota.credits_limit ← 额度上限 - -- pack.usage.credits_amount ← 已用 - -- 参考 trae-credits.ts:341 `const quota = base.quota` + - -- `readNumber(quota,'credits_limit')` 逐字如此。 - -- - -- 真实缺陷(2026-10-17 用户报「额度显示依旧异常」): - -- 我此前读的是 `p.credits_limit`(pack 顶层)—— 该字段**根本不存在** - -- ⇒ 每个包都算 0 ⇒ 总余额恒 0,而账号里其实有 600(每月登录 500 + 签到 100)。 - -- 实测上游响应(2026-10-17): - -- pack[0] 免费 quota 无 credits_limit → 跳过 - -- pack[1] 每月登录赠送 quota.credits_limit=500 usage={} - -- pack[2] 签到奖励 quota.credits_limit=100 usage={} - -- usage_summary: {total_amount:600, consumed_amount:0} - total_fn = function(rec) - local sum = 0 - local list = rec.user_entitlement_pack_list or rec.userEntitlementPackList or {} - if type(list) ~= "table" then return 0 end - for _, p in ipairs(list) do - local base = p.entitlement_base_info or p.entitlementBaseInfo - local quota = (type(base) == "table" and (base.quota or base.Quota)) or nil - local lim, amt = 0, 0 - if type(quota) == "table" then - lim = tonumber(quota.credits_limit) or tonumber(quota.creditsLimit) or 0 - end - -- 兼容:少数响应把额度放在 pack 顶层(保留旧路径,不丢) - if lim == 0 then - lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0 - end - local usage = p.usage or p.Usage - if type(usage) == "table" then - amt = tonumber(usage.credits_amount) or tonumber(usage.creditsAmount) or 0 - end - if amt == 0 then - amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0 - end - if lim > 0 then sum = sum + math.max(0, lim - amt) end - end - -- 兜底:所有包都没有 credits_limit 时,用 usage_summary 的总量减消耗。 - -- (实测「免费」包就是这样:quota 里只有 enable_* 布尔,无额度字段。) - if sum == 0 then - local us = rec.usage_summary or rec.usageSummary - if type(us) == "table" then - local tot = tonumber(us.total_amount) or tonumber(us.totalAmount) or 0 - local used = tonumber(us.consumed_amount) or tonumber(us.consumedAmount) or 0 - if tot > 0 then sum = math.max(0, tot - used) end - end - end - return sum - end, - }, - -- callback 解析模式(移植自参考 trae-oauth.ts parseTraeCallbackDetailed): - -- 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", - exchange_body = '{"ClientID":"{client_id}","RefreshToken":"{refresh_token}","ClientSecret":"-","UserID":""}', - client_id = "en1oxy7wnw8j9n", -- 实测值(对齐参考 trae-product.ts 的 clientId) - -- ↑ 这个值**不是**可选的:ExchangeToken 的请求体里 ClientID 是必填, - -- 留空会让交换稳定 400(详见文件内 traeCallback 的注释)。 - }, - }, - - -- MiniMax —— PKCE 设备码流(RFC 8628)。三步:申请码 → 用户授权 → 轮询换 - -- token。全程纯 HTTP,无需本地服务器;user_code 显示给用户输到授权页。 - { - id = "minimax", - name = "MiniMax", - vendor = "minimax", - flow = "device-poll", - device = { - -- 申请设备码:PKCE S256 - code_url = "https://account.minimax.cn/oauth2/device/code", - code_body = "client_id=mcode-public&scope=agent.default&audience=agent-backend" - .. "&code_challenge={challenge}&code_challenge_method=S256", - -- 轮询换 token - token_url = "https://account.minimax.cn/oauth2/token", - token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" - .. "&device_code={device_code}&client_id=mcode-public&code_verifier={verifier}", - interval = 5, -- 轮询间隔秒 - slow_down_guard = true, -- 响应 authorization_pending / slow_down 按标准处理 - }, - hosts = { "account.minimax.cn", "agent.minimax.cn" }, - base_url = "https://agent.minimax.cn", - -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: - -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL)。 - -- 此前 base_url 只写 host、adapter=openai,于是请求打到 - -- https://agent.minimax.cn/chat/completions —— 那是 **Web 前端**, - -- 返回 404 首页文案「MiniMax Agent: 简单指令,无限可能」。 - -- 实测(2026-10-19):/mavis/api/v1/llm/v1/messages → 401 invalid - -- access token(=路径正确、凭据问题);裸 /chat/completions 与 - -- /mavis/api/v1/chat/completions 都是 404。 - -- 照参考 minimax-product.ts MINIMAX_INFER_PATH。 - endpoint = "/mavis/api/v1/llm/v1/messages", - -- 远端模型目录(照参考 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", - }, - -- ★ 推理端点是 **Anthropic Messages 协议**(不是 OpenAI): - -- 参考 minimax-product.ts 的 `MINIMAX_INFER_PATH = '/mavis/api/v1/llm/v1/messages'` - -- 与 anthropic 基座 `adapter.endpoint = "/v1/messages"` 同族。 - -- 此前写成 openai ⇒ 即使路径改对,请求体形状也不对。 - adapter = "anthropic", - -- 照参考 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", - method = "POST", body = "{}", - }, - -- 签到状态预检(对齐参考 minimax-credits.ts,面板显示今日是否已签到) - checkin_status = { - url = "https://agent.minimax.cn/minimax-cloud/api/v1/signin/status", - method = "GET", - }, - -- 余额(对齐参考:GET /credit/details,total = Σ details[].remaining_amount) - balance = { - url = "https://agent.minimax.cn/minimax-cloud/api/v1/credit/details", - method = "GET", - total_fn = function(rec) - -- 注意 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 - sum = sum + (tonumber(d.remaining_amount) or 0) - end - return sum - end, - }, - }, - - -- 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)。 - { - id = "cline", - name = "Cline", - vendor = "cline", - flow = "device-poll", - device = { - code_url = "https://api.workos.com/user_management/authorize/device", - code_body = "client_id=client_01K3A541FN8TA3EPPHTD2325AR", - token_url = "https://api.workos.com/user_management/authenticate", - token_body = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code" - .. "&device_code={device_code}&client_id=client_01K3A541FN8TA3EPPHTD2325AR", - interval = 5, - -- WorkOS 形态:判 error 字段;slow_down 退避 1 秒;access_denied/invalid_grant 同组 - judge = "workos", - slow_down_step = 1, - }, - -- 第二步:WorkOS token → Cline token(挂 apiBase) - register = { - 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" }, - -- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。 - base_url = "https://api.cline.bot", - -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: - -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), - -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 - -- 完整路径,实际请求就变成 .../api/v1/chat/completions/chat/completions ⇒ 上游 - -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 - endpoint = "/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", - }, - -- Cline 的 429 把等待时长写在**英文句子**里(`Try again in 19h 39m`), - -- 既无 retry-after 头也无绝对时刻。通用适配器解析不出 ⇒ 内核按 30 秒 - -- 短冷却处理 ⇒ 每 30 秒重试、永远失败(在猛敲上游)。 - -- 故用本插件自装的 channels-cline 把文案归一成内核已认的中立短语。 - adapter = "channels-cline", - -- 命名约定:`cline-free/*` 是**免费通道**的独立 id(照参考 - -- cline-models.ts 的 isFree 兜底规则)。远端 free 数组未响应时靠它判免费。 - free_prefix = "cline-free/", - -- 这 3 个来自远端 free 数组(内嵌目录里没有),**它们是免费的** - -- (此前误放在 models 里 ⇒ 被标成收费,与注释自相矛盾)。 - models = { - "stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor", - }, - -- 显式声明免费集合:这是**兜底**(远端 free 数组是权威,但可能拉不到)。 - -- 三者都在这里 ⇒ 即使远端抖动也不会把免费模型误标成收费。 - models_free = { - "stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor", - }, - -- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取。 - -- 余额(对齐参考 cline-quota.ts:GET usage-limits,单位 dollars,show percentUsed) - credit_unit = "dollars", - balance = { - url = "https://api.cline.bot/api/v1/users/me/plan/usage-limits", - method = "GET", - total_fn = function(rec) - local d = rec.data or rec - local limits = d.limits or {} - if type(limits) ~= "table" then return 0 end - -- 取剩余百分比最小的那个限额的 (100-percentUsed);没有就 0 - local minRemain - for _, l in ipairs(limits) do - local used = tonumber(l.percentUsed or l.percent_used) or 0 - local rem = 100 - used - if not minRemain or rem < minRemain then minRemain = rem end - end - return minRemain or 0 - end, - }, - }, - - -- Buddy(腾讯 CodeBuddy)—— state 轮询流(照参考 buddy-oauth.ts 逆向)。 - -- 两步:POST /v2/plugin/auth/state → {state, authUrl};用户在 authUrl 完成登录后 - -- 插件轮询 GET /v2/plugin/auth/token?state=...。判据:业务码 11217 = 未就绪继续等; - -- 网络错误同样继续轮询不中断(对齐 Rust loop_get_token)。token 就绪后再拿账号。 - { - id = "buddy", - name = "CodeBuddy(腾讯)", - vendor = "tencent", - flow = "state-poll", - -- ★ 双区(2026-10-19,照参考 product.ts 的 CODEBUDDY / WORKBUDDY 两份配置): - -- * cn(国内,默认)→ copilot.tencent.com,platform=ide,X-Domain/UA 用国内值 - -- * intl(国际,WorkBuddy)→ www.workbuddy.ai,platform=workbuddy-ai, - -- productCode=workbuddy,UA=WorkBuddyIDE/5.5.2(2026-10-19 实测 - -- POST {intl}/v2/plugin/auth/state?platform=workbuddy-ai 回 200 信封) - -- 区域按**账号**记录(凭据 JSON 的 region 字段);登录请求可带 region。 - state = { - state_url = "https://{buddyhost}/v2/plugin/auth/state?platform={platform}", - token_url = "https://{buddyhost}/v2/plugin/auth/token", - account_url = "https://{buddyhost}/v2/plugin/login/account", - code_not_ready = 11217, - interval = 1, - }, - hosts = { "copilot.tencent.com", "www.codebuddy.cn" }, - -- buddy 的 chat 端点含 /v2 前缀(参考 CHAT_API_BASE = .../v2 + /chat/completions)。 - base_url = "https://copilot.tencent.com", - -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: - -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), - -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 - -- 完整路径,实际请求就变成 .../v2/chat/completions/chat/completions ⇒ 上游 - -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 - endpoint = "/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", - }, - -- ★ 用内置 buddy 适配器(而非通用 openai):buddy 的 glm-5.1 要求请求体里 - -- 必须有非空 reasoning_effort(否则 400 + 11150),且流式请求必须带 - -- Accept: text/event-stream(否则上游只回空帧、completion_tokens=0)。 - -- 该适配器复用 openai 的全部转换,只补这两处厂商要求。 - adapter = "buddy", - -- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。 - -- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。 - -- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域, - -- 跨产品迁移后会指错区);X-Product 是部署类型(SaaS); - -- X-Product-Code 是产品码(codebuddy);X-User-Id 取凭据的 user_id。 - headers_fn = function(accessToken, cred) - -- ★ 双区:凭据 region 决定身份头(照参考 product.ts 两份产品配置)。 - local region = cred and tostring(cred.region or "") or "" - local isIntl = region == "intl" or (region == "" and false) - -- 未声明 region 的存量凭据:X-Domain 快照若是 workbuddy 则按国际区, - -- 否则按国内(升级前全部是国内账号,默认值不变)。 - if region == "" then - isIntl = cred and cred.domain == "www.workbuddy.ai" or false - end - local h = { - ["Authorization"] = "Bearer " .. accessToken, - ["Accept"] = "application/json", - ["Content-Type"] = "application/json", - ["X-Domain"] = isIntl and "www.workbuddy.ai" or "copilot.tencent.com", - ["X-Product"] = "SaaS", - ["X-Product-Code"] = isIntl and "workbuddy" or "codebuddy", - ["User-Agent"] = isIntl and "WorkBuddyIDE/5.5.2" or "CodeBuddyIDE/1.106.1", - } - local uid = cred and (cred.user_id or cred.uid) - if type(uid) == "string" and uid ~= "" then h["X-User-Id"] = uid end - local eid = cred and cred.enterprise_id - if type(eid) == "string" and eid ~= "" then - h["X-Enterprise-Id"] = eid - h["X-Tenant-Id"] = eid - end - return h - end, - models = { - -- 兜底目录(远端不可用时才用)。照参考 CODEBUDDY_FALLBACK_MODELS 的取舍: - -- **只列实测可调用的**。hy4-preview-x 已实测恒 11102(后端未开放)故剔除 - -- —— 参考的兜底表里同样没有它(它靠白名单达到同样效果)。 - "deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", - "glm-5.3", "glm-5.3-flash", "glm-5.2", - }, - -- 签到状态(对齐参考 credits.ts:必须用 checkin-activity-status,不能用 - -- checkin-status —— 后者返回占位数据 active:false,是个坑) - checkin_status = { - url = "https://copilot.tencent.com/v2/billing/meter/checkin-activity-status", - method = "POST", body = "{}", - status_fn = function(rec) - local d = rec.data or rec - return { - active = d.active == true, - todayCheckedIn = d.todayCheckedIn == true or d.today_checked_in == true, - streakDays = tonumber(d.streakDays or d.streak_days) or 0, - todayCredit = tonumber(d.todayCredit or d.today_credit) or 0, - } - end, - }, - -- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource) - -- -- 注意响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 - -- data 不同),这是本接口最容易解析错的地方。 - -- -- 注意刻意**不用**服务端的 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 - 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 sum = 0 - for _, pkg in ipairs(accounts) do - if type(pkg) == "table" then - -- 失效判定:Status 显式已过期,或 ExpiredTime / DeductionEndTime 已过。 - local active = true - local st = pkg.Status - if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end - local function past(t) - if type(t) ~= "string" or t == "" then return false end - local Y, Mo, D, H, Mi, S = t:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)") - if Y then - local ts = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D), - hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) - return ts and now >= ts - end - return false - end - if past(pkg.ExpiredTime) then active = false end - local ded = tonumber(pkg.DeductionEndTime) - if ded and ded > 0 then - -- 毫秒时间戳 - if now >= math.floor(ded / 1000) then active = false end - end - if active then - sum = sum + (tonumber(pkg.CycleCapacityRemain) or 0) - end - end - end - -- 两位小数规整(服务端精确值带浮点噪声,如 55.67000031) - return math.floor(sum * 100 + 0.5) / 100 - end, - }, - }, - - -- Gemini(Google Cloud Code)—— 标准 OAuth 授权码流 + token 交换。 - -- 照参考 gemini-oauth.ts:授权页 client_id/redirect_uri/scope 齐全,回环回调 - -- 拿到 code 后 POST oauth2.googleapis.com/token 换 token(带 code_verifier)。 - -- client_id 是 Cloud Code 的公开客户端(逆向自 binary),scope 六项逐字照抄。 - { - id = "gemini", - name = "Gemini(Cloud Code)", - vendor = "google", - flow = "callback", - 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" - .. "&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", - -- 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" }, - }, - - -- LobsterAI(有道龙虾)—— 本地回调 + authCode 换 token(照参考 lobsterai-oauth.ts)。 - { - id = "lobsterai", - name = "LobsterAI(有道)", - vendor = "youdao", - flow = "callback", - -- 授权页真值:{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", - -- 参考真值 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", - -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: - -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), - -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 - -- 完整路径,实际请求就变成 .../api/proxy/v1/chat/completions/chat/completions ⇒ 上游 - -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 - endpoint = "/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", - }, - adapter = "openai", - -- 照参考 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", - method = "GET", - total_fn = function(rec) - local d = rec.data or rec - return tonumber(d.availableCredits or d.available_credits or d.credits or d.balance) or 0 - end, - }, - }, - - -- OpenCode —— 直接粘贴 API key(无登录流,参考 opencode-auth.ts)。 - { - id = "opencode", - name = "OpenCode Zen", - vendor = "opencode", - flow = "paste", - -- opencode 的 chat 路径是 /v1/chat/completions(参考 chatPath)。 - -- ★ base_url 必须保留 `/zen` 段:参考 opencode-product.ts:56 的 - -- `baseUrl: 'https://opencode.ai/zen'` + chatPath `/v1/chat/completions`。 - -- 实测(无凭据): - -- https://opencode.ai/zen/v1/chat/completions → 500(路由存在,鉴权前先炸) - -- https://opencode.ai/v1/chat/completions → 404 + HTML(网页) - base_url = "https://opencode.ai/zen", - -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是 - -- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是 - -- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成 - -- .../v1/chat/completions/chat/completions ⇒ 404 Route Not Found。 - endpoint = "/v1/chat/completions", - -- 远端模型目录(照参考 opencode-product.ts modelsPath)。 - -- ★ 端点必须是 /zen/v1/models(带 UA+x-opencode-client 头):裸打 - -- /v1/models 回的是 HTML 网页(2026-10-19 实测),id_list 解析必空。 - models_remote = { - endpoints = { { url = "https://opencode.ai/zen/v1/models", method = "GET", - headers = { ["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", - ["x-opencode-client"] = "cli" } } }, - auth = "none", - parse = "id_list", - }, - adapter = "openai", - -- models.dev 的键名(能力元数据的来源;照参考 opencode-capability) - capability_key = "opencode", - -- 该平台没有自己的免费名单,models.dev 的 cost **就是**唯一权威 - -- (照参考 opencode-capability.ts)。故本地 models_free 只作兜底。 - free_source = "capability", - -- 照参考 OPENCODE_FALLBACK_MODELS 全量(14 项)。isFree 是**官方语义 - -- 字段**(匿名可用与否):7 个 free 无需 key,其余需付费 key(实测匿名 401)。 - -- ⚠️ 2026-10-19 实测目录更新(/zen/v1/models): - -- * space-bunny-free 已 410 下架(官方指路 minimax-m3.1-flash-free) - -- * nemotron-3-ultra-free 已不在目录 - -- * 新增 beam-free / nemotron-3.5-super-vl-free / jev-1.13-free / - -- glm-5.3-flash-free / minimax-m3.1-flash-free - models = { - "big-pickle", "longcat-2.5-preview-free", - "mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free", - "nemotron-3.5-lightning-free", "glm-5.3-flash-free", - "minimax-m3.1-flash-free", "deepseek-v4.1-flash-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", "longcat-2.5-preview-free", - "mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free", - "nemotron-3.5-lightning-free", - "glm-5.3-flash-free", "minimax-m3.1-flash-free", "deepseek-v4.1-flash-free", - }, - -- ★ 目录里有、但**本协议下不可调用**的免费模型(2026-10-20 逐个实测): - -- * jev-1.13-free → 400 "Model does not support this protocol." - -- (它不是 OpenAI chat 协议,用 /v1/chat/completions 必失败) - -- * beam-free → 200 但 content 为空、无 finish_reason(上游未真正产出) - -- 二者若留在可选列表,用户选中后只会看到"空回复/报错" ⇒ 判为不可用, - -- 既不进 UI 列表,也不进上游源(models_unavailable 与 hidden 的区别见 - -- effectiveModels 的注释)。 - models_unavailable = { "jev-1.13-free", "beam-free" }, - }, - - -- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。 - -- 登录是常规企业凭据(paste);签到走 snap-access 的 SDK-HMAC-SHA256 签名, - -- 放在 claim_fn 钩子里(checkin 表驱动驱动不动签名请求,参考注释也点明这一点)。 - { - id = "codearts", - name = "CodeArts(华为)", - vendor = "huawei", - -- 不是 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", - -- benefit 模型目录(照参考 OPENGW_GATEWAY_CONFIG_URL 的 host)。 - -- 不在白名单会被 HTTP 桥拒,目录只剩第 2 个端点的那一半模型。 - "opengw.developer.huaweicloud.com" }, - -- codearts 的 chat 端点含 /api/v2 前缀(参考 CHAT_API_BASE = .../api/v2)。 - base_url = "https://snap-access.cn-north-4.myhuaweicloud.com", - -- 本插件**自己装的**适配器(见 installCodeartsAdapter):华为云 AK/SK 签名 - adapter = "codearts", - -- ★ 上游的"不可用"多数是**账号级限流**,约 1 分钟自愈,不是模型坏了: - -- * 400 `TM.00001041: 并发会话数已达上限(3个)` —— 账户并发会话上限, - -- 实测最后一个请求结束后约 44~60 秒释放; - -- * 429 `TPM limit ... 500000 tokens per minute` —— 每分钟 token 配额。 - -- 内核原本**只在 429 上做冷却、不重试**,于是这类"等一会儿就好"的拒绝 - -- 直接冒到客户端(流式还表现为 `empty stream`,因为上游用 HTTP 200 + - -- error 帧表达拒绝)。 - -- - -- 这里声明**声明式重试策略**(内核只按数据匹配,不认识 CodeArts): - -- 命中即在网关内部每 10s 重试一次,最多 18 次(≈3 分钟),客户端只是 - -- 多等几秒,不会看到错误。流式只在**握手阶段**重试(首帧尚未下发), - -- 不会重复输出。 - retry_policy = { - on_status = { 400, 429 }, - on_body_contains = { "TM.00001041", "TPM limit", "并发会话数已达上限" }, - delay = "10s", - max_attempts = 18, - }, - -- 签名平台:源注册要交**完整凭据 JSON**(适配器自己解 AK/SK 签名), - -- 不是裸 access token。见 syncAccountSource 的注释。 - credential_shape = "full", - -- ★ 远端模型目录(照参考 fetchCodeArtsRemoteModels 的**两个**端点)。 - -- - -- 缺这个曾让 codearts 只剩硬编码的 1 个模型("CodeArts-Pangu"),而 - -- 10 个账号全在池里可用 —— 目录端点其实一直可达,只是我们没打。 - -- 参考实测的模型(GLM-5.2 / openpangu 系列 / glm-5.2-sft-harmony 等) - -- 全都由这两个端点下发: - -- ① opengw gateway/config → result.models(benefit 模型,如 glm-5.3-flash) - -- ② snap-access /v1/model/builtin → builtinModels(常规模型) - -- 两者都**必须 AK/SK 签名**(ep.sign = "codearts" 走平台签名函数), - -- 且 ② 额外要 Agent-Type / X-Language 两个不参与签名的头。 - models_remote = { - endpoints = { - { url = "https://opengw.developer.huaweicloud.com/api/v1/gateway/config", - method = "GET", sign = "codearts", parse = "codearts_gateway" }, - { url = "https://snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin", - method = "GET", sign = "codearts", parse = "codearts_builtin", - headers = { ["Content-Type"] = "application/json", - ["Agent-Type"] = "PromptCenter", - ["X-Language"] = "zh-cn" } }, - }, - auth = "none", -- 签名由 sign 分支负责,不走 Bearer - parse = "codearts_builtin", - }, - -- 兜底目录:远端不可达时仍可选中(照参考的已知可用 id)。 - models = { "CodeArts-Pangu", "deepseek-v4-flash", "GLM-5.3", "GLM-5.2" }, - checkin = { - url = "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin", - method = "GET", - }, - -- claim_fn 由下方 claimCodeArts 实现(引用前向声明,见文件末尾) - claim_fn_name = "claimCodeArts", - -- ★ 续期:华为云 STS 的 security_token 只活 1 小时,不续就会 - -- `APIG.0602: the security token has expired` 拒掉每个请求。 - -- - -- 为什么必须在这里声明(真实缺陷,2026-10-19 用户报「华为云源用不了」): - -- account_refresh 走 refreshCredential(),它要求 `plat.refresh.url` 存在; - -- codearts 只声明了 refreshable=true(在 accountView 里硬编码)却没声明 - -- **怎么续** ⇒ 手动刷新恒出 `该平台未声明续期方式`,自动续期链空转, - -- 日志里反复 `模型刷新失败 codearts: HTTP 400`。 - -- - -- 为什么不能走 REFRESH_STYLES 的声明式风格: - -- 华为云 STS 要 **DPoP 头**(ES256 JWS,jwk 用凭据里存的 dpop_private_key_jwk - -- 签),而声明式 style 只发普通 JSON/form 且拿不到私钥。已有实现 - -- `codeartsRequestToken(body, key)` 做完整这件事 —— 故用 refresh_fn - -- 挂钩它,而非新增声明式 style(保持内核/通用层不理解厂商)。 - -- - -- 请求体真值(照参考 channel-pack/src/oauth.ts exchangeRefreshToken): - -- client_id=codearts-agent grant_type=refresh_token - -- refresh_token=<凭据里的> code_verifier=<登录时的> - -- ⚠️ client_id 是 **codearts-agent**,不是凭据里的 access_key_id - -- (我实测把 AK 当 client_id 会得到 `invalid client id`)。 - -- 实际绑定在文件末尾的绑定循环(refresh_fn),不在本表内联。 - }, - - -- Qoder —— **标准 PKCE 设备码轮询**(照参考 qoder-oauth.ts / qoder.ts 逐行移植)。 - -- -- 注意纠正一处长期错误:此前把 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 即完成 - -- 三个必须照抄的细节(参考注释都点了名): - -- · client_id 必须用 **prod 的**(非 prod 会让服务端在授权回调阶段拒绝) - -- · poll 挂在 openApiBase(openapi.qoder.sh),不是 authBase(那返回 401) - -- · nonce/verifier 必须原样回传(PKCE 校验) - { - id = "qoder", - name = "Qoder", - vendor = "qoder", - flow = "device-poll", - device = { - -- 授权页在 {authBase}/device/selectAccounts,query 由 pkce 表单拼装 - -- ★ 双区:host 与 client_id 都用占位符({auth} / {client_id}), - -- start_login 按会话区域从 QODER_REGION_AUTH 替换。 - code_url = "{auth}/device/selectAccounts", - code_method = "GET_QUERY", - -- code_url 的 query 模板:{challenge}/{nonce}/{machine_id}/{client_id} - auth_query = "challenge={challenge}&challenge_method=S256&nonce={nonce}" - .. "&machine_id={machine_id}&client_id={client_id}", - -- 轮询在 openApiBase。★ host 用 {openapi} 占位符(双区:intl=openapi.qoder.sh / - -- cn=openapi.qoder.com.cn),deviceOneStep 按会话区域替换。 - token_url = "https://{openapi}/api/v1/deviceToken/poll", - token_method = "GET_QUERY", - -- poll 的 query 模板:PKCE 用 verifier(不是 challenge) - poll_query = "nonce={nonce}&verifier={verifier}&challenge_method=S256", - -- 判据:404 = 继续(见 deviceOneStep 的 pending404 分支) - judge = "pending404", - interval = 2, - }, - -- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。 - base_url = "https://api2-v2.qoder.sh", - -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: - -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), - -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 - -- 完整路径,实际请求就变成 .../model/v1/chat/completions/chat/completions ⇒ 上游 - -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 - endpoint = "/model/v1/chat/completions", - -- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽) - -- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。 - -- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。 - adapter = "channels-qoder", - -- 兜底目录(照参考 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", - total_fn = function(rec) - local d = rec.data or rec - local u = (d.userQuota or {}).remaining or 0 - local a = (d.addOnQuota or {}).remaining or 0 - return (tonumber(u) or 0) + (tonumber(a) or 0) - end, - }, - }, - - -- ZCode —— **两段式服务端中介授权流**(照参考 zcode-login.ts 逐行移植)。 - -- -- 注意纠正第二处长期错误:此前标 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} - -- 判据(参考注释点名,与官方一致): - -- · 4xx(**除 408/429**)才是终态失败;5xx 与网络错误继续重试 - -- · code != 0 或 data 缺失 → pending - -- · token(zcode JWT)与 user.user_id 必需;第三方 token 取 bigmodel/zai 二者之一 - { - id = "zcode", - name = "ZCode", - vendor = "zcode", - flow = "init-poll", - initpoll = { - init_url = "https://zcode.z.ai/api/v1/oauth/cli/init", - poll_url = "https://zcode.z.ai/api/v1/oauth/cli/poll/{flow_id}", - provider = "bigmodel", - app_version = "3.14.3", - origin = "https://zcode.z.ai", - interval = 2, - }, - -- 推理走 Anthropic 协议的免费额度通道(照参考 zcode-upstream.ts): - -- 积分制无论 bigmodel/zai 都走 zcode.z.ai(官方分派表规则 2/5,2026-10-03 实测); - -- 只有订阅制才走 api.z.ai。这里用积分制那条。 - -- zcode 只存在 /api/v1/zcode-plan/anthropic/v1/messages 这一个 chat 路径 - -- (参考 zcode-anthropic.ts:7 注释:openai / 裸 v1/chat/completions 一律 - -- 404 page not found)。 - -- - -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: - -- 内核 URL = `base_url + endpoint`(provider.go ChatURL)。 - -- 此前 base_url 已含 /api/v1/zcode-plan/anthropic,而下面又声明了 - -- endpoint = "/api/v1/zcode-plan/anthropic/v1/messages" ⇒ 路径被拼两遍: - -- https://zcode.z.ai/api/v1/zcode-plan/anthropic/api/v1/zcode-plan/anthropic/v1/messages - -- → 404 page not found(生产实测 2026-10-19)。 - -- 与 v2.0.30「12 平台 base_url 侵占 endpoint」同族缺陷。 - base_url = "https://zcode.z.ai", - -- 远端模型目录(照参考 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", - -- ★ 不能复用通用 anthropic:zcode 的该通道做**请求体内容检查** —— system 缺 - -- 官方身份块结构时直接 405 + {"code":3012,...}(实测;与 HTTP 头、版本号、 - -- 请求频率、tools 声明全无关)。本插件自装的 channels-zcode 复用 anthropic - -- 的协议转换,只额外注入身份块(见 ZCODE_ADAPTER_SRC)。 - adapter = "channels-zcode", - -- 余额端点(需要 Authorization;照参考 ZCODE_BILLING_BALANCE_URL) - balance = { - url = "https://zcode.z.ai/api/v1/zcode-plan/billing/balance", - method = "GET", - total_fn = function(rec) - local d = rec.data or rec - -- 优先取明确的余额字段;取不到就不编造(返回 nil 让调用方报错) - return tonumber(d.balance or d.remaining or d.credits or d.total) - end, - }, - -- 兜底目录(照参考 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 逆向)。 - -- 扫码要在微信 App 里完成,纯 Lua 环境拿不到微信凭据,标 manual。 - { - id = "loomy", - name = "Loomy", - vendor = "loomy", - -- 注意纠正第三处长期错误:此前标 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) - -- 判据(参考点名;早期版本**读反了**导致"扫码后卡在已扫码"): - -- 408=等待扫码;404=已扫码待确认(继续);405=**已确认**(wx_code 在此帧); - -- 403=用户取消;402=二维码失效 - flow = "wechat-poll", - wechat = { - -- appid / redirect_uri 照参考常量(原写的 appid 是我编的,上游不认); - -- 末尾 `#wechat_redirect` 是微信要求的固定片段,缺它对某些客户端会跳错页。 - auth_url = "https://open.weixin.qq.com/connect/qrconnect" - .. "?appid=wx18d60be432287cf8&scope=snsapi_login&response_type=code" - .. "&redirect_uri=https%3A%2F%2Floomy.xunfei.cn%2Foauth%2Fwechat%2Fcallback" - .. "&state={state}#wechat_redirect", - qr_url = "https://open.weixin.qq.com/connect/qrcode/{uuid}", - poll_url = "https://long.open.weixin.qq.com/connect/l/qrconnect", - -- 逐字照参考 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", - -- 参考真值 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", - }, - -- loomy 的 chat 端是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494)。 - -- - -- ★ host 是 **loomyad.xunfei.cn/api/v1**(参考 LOOMY_API_BASE),不是 - -- `loomy.xunfei.cn` —— 后者是 **Web 前端/OAuth 回调**域(本块的 - -- wechat.redirect_uri 正用它)。实测: - -- POST https://loomy.xunfei.cn/chat/completions - -- → 200 + ``(网页!根本不是 API) - -- POST https://loomyad.xunfei.cn/api/v1/chat/completions - -- → 200 + `{"code":"100002","desc":"缺少 token"}`(正常 JSON) - -- 写成前端域时,无论路径怎么拼都不可能是 API。 - -- - -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是 - -- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是 - -- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成 - -- .../chat/completions/chat/completions ⇒ 上游回 - -- `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 - base_url = "https://loomyad.xunfei.cn/api/v1", - endpoint = "/chat/completions", - adapter = "openai", - -- 兜底目录(照参考 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 逆向)。 - -- 扫码是图片码,纯 Lua 环境无法展示与回取,标 manual。 - { - 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 - -- 参考注释:code 由**宿主侧本地随机生成**(服务端接受任意自造 code 并进入 - -- pending),故完全绕开收不到的自定义协议回调。 - -- 短信路径(/sms/send + /sms/verify)需要阿里云滑块 captcha_param 与手机号 - -- AES 加密 —— 那一条才真需要人工,故不走。 - flow = "qr-poll", - -- ★★ 2026-10-20 实测重写(用户报障「商汤那个链接点进去是无效链接」)。 - -- 旧实现两个致命错: - -- ① code **本地随机造**,但上游早已不认自造码:旧端点 - -- /api/web/auth/v1/login_with_qrcode_code 对任意 code 恒回 pending - -- (服务端根本没这个会话)⇒ 扫码永远不会完成,账号一直是空凭据。 - -- ② 登录页 /login/mp **已 404**(站点 Next 路由已删)⇒ 点开是无效链接。 - -- 现行流程(逐行对齐上游网页客户端 2026-10-20 的 auth chunk): - -- ① POST {base}/api/iam/v1/auth/mp/qrcode → {qrcode_code, scan_path, - -- expires_in, poll_interval}(**服务端建会话**) - -- ② 二维码/链接 = {base}{scan_page}?code=…&appname=…&source=platform - -- 页面用 /login(实测 200,且渲染「微信扫码」面板并接受 code 预置); - -- 服务端回的 scan_path 是 ""/login/mp"",该路由已 404,故以 scan_page 为准, - -- scan_path 仅在它非 "/login/mp" 时采用(上游换路由时自动跟随)。 - -- ③ 轮询 POST {base}/api/iam/v1/auth/mp/qrcode/login {qrcode_code} - -- → {code:"OK", data:{status: pending|logging|canceled|success, access_token?}} - -- 旧端点保留为 legacy_poll_path 兜底(IAM 不可用时退回旧行为)。 - qr = { - api_base = "https://xiaohuanxiong.com", - auth_prefix = "/api/web/auth/v1", -- 旧端点前缀(兜底) - poll_path = "/login_with_qrcode_code", -- 旧轮询(兜底) - create_path = "/api/iam/v1/auth/mp/qrcode", - iam_poll_path = "/api/iam/v1/auth/mp/qrcode/login", - scan_page = "/login", - login_page = "/login/mp", -- 上游已废弃,仅作兼容判断 - app_name = "商汤小浣熊官网", - source = "platform", - interval = 2, - }, - -- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions) - -- raccoon 的 chat 端点是 /api/web/llm/v2/chat/completions。 - base_url = "https://xiaohuanxiong.com", - -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: - -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), - -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 - -- 完整路径,实际请求就变成 .../api/web/llm/v2/chat/completions/chat/completions ⇒ 上游 - -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 - endpoint = "/api/web/llm/v2/chat/completions", - adapter = "openai", - -- 兜底目录(照参考 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", - method = "GET", - headers_fn = function(accessToken, cred) - return { - ["Authorization"] = "Bearer " .. accessToken, - ["Accept"] = "application/json", - ["Content-Type"] = "application/json", - ["X-Org-Code"] = (cred and cred.office_identity) or "", - ["X-Raccoon-Language"] = "zh", - } - end, - total_fn = function(rec) - local d = rec.data or rec - return tonumber(d.available_points or d.availablePoints or d.points) - end, - }, - -- 一次性新人奖励(对齐参考 RACCOON_LOGIN_REWARD_POINTS) - onboarding = { reward_points = 3000 }, - }, -} - --- 华为云 snap-access 引擎域名(连接与签名都用它)。 - --- 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 - 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", plugin.K.CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil) - if not req then return nil, err end - local resp, herr = modelrouter.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", - plugin.K.CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil) - if not areq then return nil, "活动列表请求构造失败" end - local aresp, aerr = modelrouter.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", plugin.K.CODEARTS_SNAP .. "/v1/ops/claim", cred, body) - if not creq then return nil, "claim 请求构造失败" end - local cresp, cerr = modelrouter.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", plugin.K.CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody) - if freq then - local fresp, ferr = modelrouter.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 - --- b64urlDecode:base64url → 二进制。 --- --- 为什么自己写:内核只导出 `base64_encode`(vm.go:1363),**没有**decode。 --- 而续期需要把 JWK 的 d/x/y(都是 base64url 字符串)转回原始字节。 -local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" -local b64rev = {} -for i = 1, #b64chars do b64rev[b64chars:sub(i, i)] = i - 1 end -local function b64urlDecode(s) - s = tostring(s or ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "") - local out, buf, nbits = {}, 0, 0 - for i = 1, #s do - local v = b64rev[s:sub(i, i)] - if v then - buf = buf * 64 + v - nbits = nbits + 6 - -- ★★ `buf = buf % (2 ^ nbits)` 是必须的:只在取字节时 % 256 而不清掉 - -- 已用掉的高位,buf 会持续累积。短输入(32 字节)碰巧全对,长输入 - -- 则 double 精度耗尽、math.floor 给出越界值 ⇒ string.char 抛 - -- invalid value。判据 TestB64urlDecodeHandlesLongInput 专钉这条。 - while nbits >= 8 do - nbits = nbits - 8 - out[#out + 1] = string.char(math.floor(buf / (2 ^ nbits)) % 256) - buf = buf % (2 ^ nbits) - end - end - end - return table.concat(out) -end - --- derLen:DER 长度字段(短/长格式)。 -local function derLen(n) - if n < 128 then return string.char(n) end - local bytes = {} - while n > 0 do -table.insert(bytes, 1, string.char(n % 256)) -n = math.floor(n / 256) - end - return string.char(0x80 + #bytes) .. table.concat(bytes) -end - --- derTLV(tag, value):最小 DER 编码单元。 -local function derTLV(tag, value) - return tag .. derLen(#value) .. value -end - --- codeartsKeyFromJwk(jwk):把持久化的 DPoP 私钥 JWK **重建**成可签名 key。 --- --- 为什么需要它(真实缺陷,2026-10-19): --- 登录时 `codeartsNewDpop()` 生成 PEM,返回 { pem, public, private }; --- 但凭据里**只持久化了 private(JWK)** —— pem 是临时文件,登录后即删。 --- 而 `codeartsDpopSign(key,...)` 走 openssl 命令行,**必须**要 `key.pem` --- 字符串。直接把 { private = jwk } 丢进去会在 `pf:write(key.pem)` 报 --- `bad argument #1 to 'write' (string expected, got nil)`。 --- --- 做法:用 d/x/y 手工组 SEC1 `EC PRIVATE KEY` 的 DER,再 PEM 包装。 --- 结构与 openssl 生成的一致(已用实测 PEM 对照过字段顺序): --- SEQUENCE --- INTEGER 1 (version) --- OCTET STRING <32B d> (privateKey) --- [0] OID 1.2.840.10045.3.1.7 (prime256v1) --- [1] BIT STRING <04||X||Y> (publicKey) --- --- 输出形状与 codeartsNewDpop() **完全一致**(pem + public + private), --- 于是 codeartsDpopSign / codeartsCredentialFrom 无需分叉。 -local function codeartsKeyFromJwk(jwk) - local d = b64urlDecode(jwk.d) - local x = b64urlDecode(jwk.x) - local y = b64urlDecode(jwk.y) - if #d ~= 32 or #x ~= 32 or #y ~= 32 then -return nil, "DPoP 私钥 JWK 字段长度异常(d=" .. #d .. " x=" .. #x .. " y=" .. #y .. ")" - end - -- OID 1.2.840.10045.3.1.7 (prime256v1) 的 DER 字节 - -- OID 1.2.840.10045.3.1.7(prime256v1)的**完整 TLV**(\6=tag \8=len)。 - -- ★ 这里只需一次 TLV:字符串里已有 tag+len,再套一层会得到 - -- 06 0a 06 08 … —— DER 长了 2 字节,openssl 报 - -- `Could not find private key` / `provider=default`,而**不**说结构错。 - -- 我第一版就是这样(真实 PEM 121 字节 vs 我的 123 字节)。 - local oid = "\6\8*\134H\206=\3\1\7" - local pub = "\4" .. x .. y -- 未压缩点:04 || X || Y - local body = - derTLV("\2", "\1") .. -- INTEGER 1 - derTLV("\4", d) .. -- OCTET STRING d - derTLV("\160", oid) .. -- [0] parameters(oid 已含 tag+len) - derTLV("\161", derTLV("\3", "\0" .. pub)) -- [1] publicKey(BIT STRING 前导 0 位) - local der = derTLV("\48", body) -- SEQUENCE - -- PEM 包装:EC PRIVATE KEY + base64 + 64 字符换行 - local b64 = base64_encode(der) - local lines = { "-----BEGIN EC PRIVATE KEY-----" } - for i = 1, #b64, 64 do lines[#lines + 1] = b64:sub(i, i + 63) end - lines[#lines + 1] = "-----END EC PRIVATE KEY-----" - return { -pem = table.concat(lines, "\n") .. "\n", -public = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y) }, -private = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y), -d = b64urlRaw(d) }, - }, nil -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):plugin.K.CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket --- ⚠️ 常量收进**表**(`plugin.consts`)而不是顶层 `local`。 --- 原因很实际:本文件顶层 local 已**顶到 Lua 单函数 200 个上限**,多一个就让 --- 整个插件**加载失败**(`too many local variables`);报错在编译期, --- 运行期表现是"插件所有路由变成 404",毫无指向性。2026-10-14 实测踩到。 --- 往本文件加顶层 local 前,务必先 `luac -p` 验证。 -local CODEARTS = { - portal_authorize = "https://codearts.huaweicloud.com/portal/authorize", - sts_token = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens", - client_id = "codearts-agent", - redirect_path = "/oauth/callback", - login_plugin_name = "snap_AIIDE", - login_plugin_version = "5.2.0", - oauth_theme = "2", - 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/modelrouter-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/modelrouter-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/modelrouter-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 = modelrouter.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 - --- refreshCodeArts(plat, cred):用已有 refresh_token 静默续一次华为云 STS token。 --- --- 照参考 channel-pack/src/oauth.ts `exchangeRefreshToken`: --- requestToken({ client_id: CLIENT_ID, code_verifier, --- grant_type: GRANT_REFRESH_TOKEN, refresh_token }, keyPair) --- 请求体四个字段缺一不可(少 code_verifier 会被拒)。 --- --- ★ 两个易错点(我自己踩过,2026-10-19): --- 1. client_id 是 **codearts-agent**(CODEARTS.client_id), --- **不是**凭据里的 access_key_id —— 把 AK 当 client_id 会得到 --- `STS5.1806 invalid refresh token: 'invalid client id: HSTACS...'`。 --- 2. **refresh_token 是一次性的**:换成功一次后旧值立即作废 --- (再发一次得 `the refresh token has been used`)⇒ 响应里的新 --- refresh_token **必须**回写,否则下一轮永久失去续期能力。 --- 这正是本函数用 codeartsCredentialFrom 重组整个凭据的原因。 --- --- 返回 (newCredTable, nil) | (nil, err)。失败信息要能定位(带上游 error_code)。 -local function refreshCodeArts(plat, cred) - local rtoken = cred.refresh_token - local verifier = cred.code_verifier - if type(rtoken) ~= "string" or rtoken == "" then - return nil, "无 refresh_token,需重新登录" - end - if type(verifier) ~= "string" or verifier == "" then - return nil, "无 code_verifier,需重新登录" - end - local jwk = cred.dpop_private_key_jwk - if type(jwk) ~= "table" then return nil, "无 DPoP 私钥,需重新登录" end - -- DPoP 私钥必须从凭据里恢复(同一个 key 才能让 STS 认这是同一客户端)。 - -- - -- ★ 这里有一个真实的形状差异(我自己踩过): - -- `codeartsNewDpop()` 返回 { pem, public, private },但凭据里 - -- **只保存了 private(JWK)** —— pem 是登录时的临时文件,没有持久化。 - -- 而 `codeartsDpopSign(key, ...)` 要的是 `key.pem` 字符串(它走 - -- openssl 命令行签名)。所以直接传 { private = jwk } 会在 - -- `pf:write(key.pem)` 报 `bad argument #1 to 'write' (string expected, got nil)`。 - -- - -- 修法:用 JWK 的 d/x/y 重建 PEM(EC PRIVATE KEY 的 SEC1 格式, - -- openssl 认得)。重建后的 key 同时含 pem / public / private, - -- 与 codeartsNewDpop() 的返回形状一致,后续逻辑无需分叉。 - local key, kerr = codeartsKeyFromJwk(jwk) - if not key then return nil, tostring(kerr) end - local token, terr = codeartsRequestToken({ - client_id = CODEARTS.client_id, - code_verifier = verifier, - grant_type = "refresh_token", - refresh_token = rtoken, - }, key) - if not token then return nil, tostring(terr) end - -- token.refresh_token 若上游给了新的就用新的;没给则沿用旧值 - -- (华为云实测会返回新的;保留旧值做兜底不会更差) - if type(token.refresh_token) ~= "string" or token.refresh_token == "" then - token.refresh_token = rtoken - end - return codeartsCredentialFrom(token, { verifier = verifier }, key) -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 = modelrouter.http({ - url = url, method = "GET", - headers = { ["Content-Type"] = "application/json;charset=UTF-8", - ["plugin-name"] = pluginName or plugin.K.CODEARTS_TICKET_PLUGIN_NAME, - ["plugin-version"] = pluginVersion or plugin.K.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_CREDENTIAL_ENDPOINT = - "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket" - --- 生成 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" - --- qoderRegionBase(cred):该账号区域的 SASH/OpenAPI 基址。 --- ★ 双区(2026-10-19,照参考 qoder-product.ts 的 QODER / QODER_CN 两份配置)。 --- 端点表在 plugin.K.QODER_REGION_BASE / QODER_REGION_AUTH(主 chunk local --- 数已顶到 Lua 上限 200,新增常量必须进 plugin.K —— 见 TestChannelsLuaHasLocalHeadroom)。 --- ⚠️ CN 没有可用的公开 chat 端点(参考注释明确)→ CN 账号**只做登录/领取**,不产源。 --- 区域按**账号**记录(凭据 JSON 的 region 字段);未声明 region 的存量凭据 --- 一律视为 intl(与升级前行为一致)。 -local function qoderRegionBase(cred) - local region = type(cred) == "table" and tostring(cred.region or "") or "" - if region == "" then region = "intl" end - -- set_endpoints 的 qoder_sash_base 覆盖**优先**于区域表:测试把上游指到 - -- httptest 假服务器靠它;此时区域表必须让路,否则请求仍打真上游被出站 - -- 白名单拒绝(实测 TestQoderClaim* 全挂在这个上)。 - if QODER_SASH_BASE ~= "https://openapi.qoder.sh" then - return QODER_SASH_BASE - end - return plugin.K.QODER_REGION_BASE[region] or QODER_SASH_BASE -end - --- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。 - --- 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 + plugin.K.QODER_UTC8_OFFSET_MS) / 1000)) - return (utc8.hour or 0) >= plugin.K.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 = modelrouter.http({ - url = qoderRegionBase(cred) .. "/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, _ = modelrouter.http({ url = qoderRegionBase(cred) .. "/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 = modelrouter.http({ - url = qoderRegionBase(cred) .. "/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):完整活动领取(多活动逐个领)。 --- ★ 凭据形状必须兼容**裸 token**:qoder 是 PKCE 设备码流且无 register 第二跳, --- complete_login 存进凭据库的就是裸 access_token 字符串(credentialOf 走裸快路 --- 返回 (tok, nil, nil)),credArgs.credential 因此是 nil。此前在这里硬要求 --- table,导致"测试通过(token 路径)、领取失败(缺凭据对象)"的自相矛盾。 --- 活动接口只要 Bearer token,裸 tok 非空时就地包一层。 -local function qoderClaim(plat, tok, args) - local cred = (args and args.credential) or nil - if type(cred) ~= "table" then - if type(tok) == "string" and tok ~= "" then - cred = { access_token = tok } - else - return nil, "缺少凭据对象" - end - 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 - -- ★ covers_today = false:这次看到的是**刷新前那一轮**的痕迹, - -- 因此**不得**据此认定"今天已处理"。 - -- - -- 参考的真实缺陷(2026-10-02,每天静默漏领 100 积分):上游 10:00(UTC+8) - -- 才刷新活动,而自动签到在上午跑 —— 上午那次看到的是**昨天**那条 CLAIMED。 - -- 若拿它记账,当天 10 点刷新后就再也不会去领。 - -- - -- 修法不靠"给每个渠道配刷新时刻表"(必然漂移),而是**让产出方自己 - -- 回答**"我这条结果覆盖的是不是今天这一轮";记账方只数 - -- "有几条能证明今天已被处理",完全不需要知道任何渠道的日界细节。 - return { done = true, kind = "inactive", covers_today = false, - message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新)," - .. "当前看到的是昨天那一轮,请稍后再来领取。" } - end - return { done = true, kind = "already-claimed", covers_today = true, - 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 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/modelrouter-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, plugin.K.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 = modelrouter.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 = modelrouter.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 = modelrouter.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 - -- 绑定 codearts 的续期钩子。 - -- - -- ★ 为何必须在这里挂而不是在平台表里内联: - -- PLATFORMS 在第 ~900 行,而 refreshCodeArts 定义在第 ~1720 行。 - -- 在表里写 `refresh_fn = function(...) return refreshCodeArts(...) end` - -- 会闭包捕获**当时的全局 nil**(该名还是 nil)—— 调用时抛错, - -- 与 trae 那次 headers_fn 同一坑(当时只加 auth 无效、表内内联也无效)。 - -- 在文件末尾绑定则天然拿到已定义的值。 - for _, p in ipairs(PLATFORMS) do - if p.id == "codearts" then - p.refresh_fn = function(plat, cred) return refreshCodeArts(plat, cred) end - break - end - end -end - - --- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。 --- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。 -local function declaredHosts() - local seen, out = {}, {} - for _, p in ipairs(PLATFORMS) do - for _, h in ipairs(p.hosts or {}) do - local hn = h:lower() - if not seen[hn] then seen[hn] = true out[#out+1] = hn end - end - end - -- 上游 base_url 的 host 也要(发对话/签到用) - for _, p in ipairs(PLATFORMS) do - local h = (p.base_url or ""):match("^https?://([^/:]+)") - if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end - end - for _, p in ipairs(PLATFORMS) do - local h = (p.checkin and p.checkin.url or ""):match("^https?://([^/:]+)") - if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end - end - -- 各 flow 自己的端点域名也要(否则授权页/长轮询/账号端点会被出站白名单拦)。 - -- 这些域名与 base_url 往往不同:Loomy 的授权在 open.weixin.qq.com、 - -- 长轮询在 long.open.weixin.qq.com、账号在 account.xfyun.cn; - -- ZCode 的 init/poll 在 zcode.z.ai(与 base_url 同域但显式声明更清楚)。 - local function addHost(u) - local h = (u or ""):match("^https?://([^/:]+)") - if h and not seen[h:lower()] then seen[h:lower()] = true out[#out+1] = h:lower() end - end - for _, p in ipairs(PLATFORMS) do - if p.wechat then - addHost(p.wechat.auth_url); addHost(p.wechat.qr_url) - addHost(p.wechat.poll_url); addHost(p.wechat.account_base) - end - if p.initpoll then addHost(p.initpoll.init_url); addHost(p.initpoll.poll_url) end - 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 - -- ★ 双区占位符 URL(buddy {buddyhost} / qoder {auth}、{openapi})在 addHost - -- 里提取不到真实 host,这里把两个区域的 host 直接补进声明,否则出站被拒 - -- (参考 WorkBuddy www.workbuddy.ai、Qoder CN openapi.qoder.com.cn)。 - if p.id == "buddy" then addHost("https://www.workbuddy.ai") end - if p.id == "qoder" then - addHost("https://qoder.cn"); addHost("https://openapi.qoder.com.cn") - 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 - end - table.sort(out) - return out -end -plugin.hosts = declaredHosts() - --- bodyOf 把 handler 的 req.body(**JSON 字符串**)解成 table。 --- --- 为什么需要它:handler 收到的是原始 body 字符串,不是解码后的表(内核不猜 --- 插件的载荷形状)。载荷坏掉时返回空表而不是报错 —— 调用方随后会因为缺少 --- 必要字段而得到明确的错误,比一个 JSON 解析异常更好读。 -local function bodyOf(req) - if not req or not req.body or req.body == "" then return {} end - if type(req.body) == "table" then return req.body end - local ok, v = pcall(json.decode, req.body) - if not ok or type(v) ~= "table" then return {} end - return v -end - --- ============================================================================ --- device-poll 通用执行器(移植自参考实现 minimax-oauth.ts / cline-oauth.ts) --- ============================================================================ --- 行为逐字对齐参考实现,测试用例也照它搬(见 channels_device_test.go): --- --- 1. PKCE S256:verifier = 随机字节 base64url;challenge = SHA256(verifier) --- 的 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 字段。 --- 3. 轮询由页面驱动(poll_login 每次调用推进一步),不在 Lua 里阻塞循环: --- 插件单 VM 单锁,阻塞循环会卡住本插件的全部钩子。 - --- b64url:内核的 base64_encode 是标准 base64;PKCE 要 base64url(无 +/= )。 --- ---- 讯飞账号(CAccount)HMAC-SHA1 签名(Loomy 用)---- --- 逐段复刻参考 loomy-sign.ts。任何偏差都会让账号端点返回鉴权失败。 --- --- 内核只有 sha256/hmac_sha256 —— 没有 sha1,也没有 md5。但插件有完整 OS 能力, --- 直接用 openssl(本机已装)算 HMAC-SHA1 与 MD5,不在 Lua 里重写密码学原语。 --- --- 签名字符串 9 段以 \n 连接,**后两段恒为空串**(不发 x-* 头), --- 故最终字符串以两个换行结尾 —— 不要"顺手"去掉尾随换行。 --- 认证头前缀是 `account {ak}:{sig}`(**不是** Bearer)。 - --- rfc3986Escape:encodeURIComponent 等价 + 补转 ! ' ( ) * -rfc3986Escape = function(v) - v = tostring(v or "") - v = v:gsub("([^A-Za-z0-9%-%._~])", function(c) - return string.format("%%%02X", string.byte(c)) - end) - -- encodeURIComponent 不转 ! ' ( ) * ~,但参考的 escapeRfc3986 要转前五个 - v = v:gsub("!", "%%21"):gsub("'", "%%27"):gsub("%(", "%%28") - :gsub("%)", "%%29"):gsub("%*", "%%2A") - return v -end - --- opensslDigest:调 openssl 算摘要。mode 是 openssl 的算法名(md5/sha1)。 --- hmacKey 非空时用 -hmac;输出 base64(参考用的就是 base64)。 -local function opensslDigest(mode, data, hmacKey) - -- 用临时文件传原文:命令行的引号/换行/二进制都不可靠 - local inPath = "/tmp/modelrouter-sig-in-" .. tostring(math.random(1e12)) - local f = io.open(inPath, "wb") - if not f then return nil, "无法写临时文件" end - f:write(data) - f:close() - local cmd - if hmacKey and hmacKey ~= "" then - local keyPath = inPath .. ".key" - local kf = io.open(keyPath, "wb") - if not kf then os.remove(inPath) return nil, "无法写密钥文件" end - kf:write(hmacKey) - kf:close() - -- 外层用单引号包 Lua 字符串,内部才能安全地带 shell 的双引号 - cmd = ('openssl dgst -%s -hmac "$(cat %s)" -binary %s 2>/dev/null | base64 -w0') - :format(mode, keyPath, inPath) - local h = io.popen(cmd) - local out = h and h:read("*a") or "" - if h then h:close() end - os.remove(keyPath) - os.remove(inPath) - return (out ~= "" and (out:gsub("%s+$", ""))) or nil - end - cmd = ("openssl dgst -%s -binary %s 2>/dev/null | base64 -w0"):format(mode, inPath) - local h = io.popen(cmd) - local out = h and h:read("*a") or "" - if h then h:close() end - os.remove(inPath) - return (out ~= "" and (out:gsub("%s+$", ""))) or nil -end - --- loomyContentMd5:空 body 返回**空串**(不是空串的 md5),与客户端一致。 -local function loomyContentMd5(body) - if not body or body == "" then return "" end - return opensslDigest("md5", body, nil) or "" -end - --- loomySigningString:9 段拼串(method,path,query,md5,ctype,date,nonce,"","") -local function loomySigningString(method, path, query, body, contentType, date, nonce) - local md5 = loomyContentMd5(body) - return table.concat({ - method:upper(), path, query or "", md5, contentType or "", - date, nonce, "", "", - }, "\n") -end - --- loomyAuthHeaders:生成讯飞账号请求头。 -local function loomyAuthHeaders(ak, aks, method, path, body, contentType) - local date = os.date("!%a, %d %b %Y %H:%M:%S GMT") - -- nonce 只要唯一即可(服务端不校验格式)。这里**不能调 newToken** —— - -- 它定义在文件后段(1518 行),Lua 词法作用域下此处是 nil,调用会崩。 - -- 用内核全局 sha256_hex(随时可用)造一个 32 字节 hex,与参考的 UUID 同量级。 - local nonce = string.format("%s-%s-%s", - sha256_hex(tostring(os.time()) .. tostring(math.random(1e12))):sub(1, 8), - sha256_hex(tostring(math.random(1e12))):sub(1, 4), - sha256_hex(tostring(math.random(1e12))):sub(1, 12)) - local ctype = contentType or "application/json" - local sts = loomySigningString(method, path, "", body, ctype, date, nonce) - local sig, err = opensslDigest("sha1", sts, aks) - if not sig then return nil, err or "签名计算失败" end - local h = { - ["Authorization"] = "account " .. ak .. ":" .. sig, - ["Date"] = date, - ["Nonce"] = nonce, - ["Content-Type"] = ctype, - } - local md5 = loomyContentMd5(body) - if md5 ~= "" then h["Content-MD5"] = md5 end - return h -end - --- devicePkce 生成一次授权的 verifier/challenge 对(SHA256 S256)。 --- verifier 自造:熵拼接后哈希。不依赖文件后段才定义的 newToken —— Lua 词法 --- 作用域下那会是 nil 全局调用。熵用 os.time + math.random 两次哈希拼接。 -local function devicePkce() - local v = sha256_hex(tostring(os.time and os.time() or 0) .. math.random(1e12)) - .. sha256_hex(tostring(math.random(1e12))) - return v, b64url(hexToBin(sha256_hex(v))) -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) --- sess 携带 device 上下文(device_code/verifier/interval),被本函数原地更新 ---(slow_down 后 interval 增大,与参考一致)。 -local function deviceOneStep(plat, sess) - local d = plat.device - local url, method, body = d.token_url, d.token_method or "POST", nil - -- ★ 双区(2026-10-19):会话创建时记录的 region 决定 poll 端点。 - -- 平台声明里 token_url 的 host 部分用 {openapi} 占位符表达;按会话区域 - -- 换成对应基址。未用占位符的平台不受影响(gsub 找不到就不动)。 - -- setmetatable+__index 只换 token_url 一个键,其余仍读原表。 - if type(d.token_url) == "string" and d.token_url:find("{openapi}", 1, true) then - local region = tostring(sess.region or "intl") - d = setmetatable({ token_url = (d.token_url:gsub("{openapi}", - plugin.K.QODER_REGION_BASE[region] or QODER_SASH_BASE)) }, { __index = d }) - end - if d.poll_query or (d.token_method == "GET_QUERY") then - -- 形态②(Qoder):GET + query 模板。PKCE 用 verifier(不是 challenge), - -- 且 nonce 必须是**会话创建时那一个**(服务端按它找回待授权会话)。 - local q = (d.poll_query or "") - :gsub("{nonce}", sess.device_nonce or sess.nonce or "") - :gsub("{verifier}", sess.device_verifier or "") - :gsub("{challenge}", sess.device_challenge or "") - :gsub("{device_code}", sess.device_code or "") - url = d.token_url .. "?" .. q - method = "GET" - else - body = (d.token_body - :gsub("{device_code}", sess.device_code or "") - :gsub("{verifier}", sess.device_verifier or "") - :gsub("{challenge}", sess.device_challenge or "")) - end - local resp, err = modelrouter.http({ - url = url, - method = method, - headers = { ["Accept"] = "application/json", ["Content-Type"] = "application/x-www-form-urlencoded" }, - body = body, - }) - if err then return false end -- 网络抖动不算失败:与参考一致(pending 语义) - local ok, rec = pcall(json.decode, resp.body or "") - 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 同组判失败。 - -- 退避步长不硬编码:slow_down_step 缺省 5(MiniMax 语义)。 - local workos = (d.judge == "workos") - local status = rec.status - local ecode = rec.error - local step = tonumber(d.slow_down_step) or 5 - - -- 「404/pending = 继续轮询」形态(Qoder)。 - -- -- 实测依据(参考注释):qoder.com 的 poll 路径返回 401,而 - -- openapi.qoder.sh 对「无待授权会话」返回 **404**,语义是"还没有会话,继续等" - -- 而非失败。把 404 当失败会让登录永远失败。 - -- 判定由平台声明 judge="pending404" 驱动,不在这里硬编码平台名。 - if d.judge == "pending404" then - if resp.status == 404 then return false end - if resp.status == 200 then - -- 2xx 但尚无 token:同样继续轮询 - local tok = rec.token or rec.device_token or rec.access_token - if tok == nil or tok == "" then return false end - return true, { ok = true, access_token = tok, - refresh_token = rec.refresh_token or rec.refreshToken, - account = rec.user_name or rec.userName or "", - uid = rec.user_id or rec.userId, - expires_at = rec.expires_at or rec.expiresAt, - refresh_expires_at = rec.refresh_token_expires_at or rec.refreshTokenExpiresAt } - end - return true, { ok = false, reason = "error", - message = "登录服务返回异常(HTTP " .. tostring(resp.status) .. ")" } - end - - if workos then - if ecode == "authorization_pending" then return false end - if ecode == "slow_down" then - sess.device_interval = (sess.device_interval or d.interval or 5) + step - return false - end - if ecode == "access_denied" or ecode == "expired_token" or ecode == "invalid_grant" then - local msg = rec.error_description or (ecode == "access_denied" and "用户拒绝了授权" or "授权已过期") - return true, { ok = false, reason = (ecode == "access_denied" and "denied" or "expired"), message = msg } - end - if resp.status == 200 and rec.access_token and rec.refresh_token then - return true, { ok = true, access_token = rec.access_token, refresh_token = rec.refresh_token, - need_register = true, account = "cline" } - end - if resp.status ~= 200 and next(rec) ~= nil then - return true, { ok = false, reason = "error", - message = "WorkOS token 轮询失败(HTTP " .. tostring(resp.status) .. ")" - .. (rec.error_description and (" - " .. rec.error_description) or "") } - end - return false -- 2xx 但既无 error 又无 token:等下一拍(避免误报死循环) - end - - -- MiniMax 形态(原样保留,已由决策表测试钉死) - if resp.status == 200 and status == "pending" then return false end - if resp.status == 200 and status == "slow_down" then - sess.device_interval = (sess.device_interval or d.interval or 5) + step - return false - end - if resp.status == 200 and (status == "denied" or status == "access_denied") then - return true, { ok = false, reason = "denied", message = "用户拒绝了授权" } - end - if resp.status == 200 and (status == "expired" or status == "expired_token") then - return true, { ok = false, reason = "expired", message = "设备码已过期,请重新发起登录" } - end - if ecode == "authorization_pending" then return false end - if ecode == "slow_down" then - sess.device_interval = (sess.device_interval or d.interval or 5) + step - return false - end - if resp.status == 200 and rec.access_token then - return true, { ok = true, access_token = rec.access_token, - refresh_token = rec.refresh_token, account = rec.nickname or rec.sub or "" } - end - return true, { ok = false, reason = "error", message = "授权失败: " .. tostring(ecode or ("HTTP " .. tostring(resp.status))) } -end - --- wechatExchange:把微信 wx_code 换成 Loomy 凭据(讯飞账号端点,HMAC-SHA1 签名)。 --- --- 参考链路(loomy-oauth.ts 注释):bindAuthThirdAccount(code) → {bind, rcode, nickname} --- → 若已绑手机号可 bindSkip(rcode) 直接拿 session;未绑则要走 SMS(需人工输入验证码)。 --- 这里做到 bindAuth 一步:拿到 rcode/nickname 即视为可用的登录上下文, --- 并把 bind 标志带进凭据,让上层知道是否需要补手机号(不假装 SMS 已完成)。 -local function wechatExchange(plat, wxCode) - local w = plat.wechat - if not w then return nil, "平台未声明 wechat 配置" end - local path = w.bind_path - local body = json.encode({ - tcode = { code = wxCode }, - type = "wx", - appId = w.app_id, - }) - local headers, herr = loomyAuthHeaders(w.access_key_id, w.access_key_secret, - "POST", path, body, "application/json") - if not headers then return nil, "签名失败: " .. tostring(herr) end - headers["Accept"] = "application/json" - local resp, err = modelrouter.http({ - url = w.account_base .. path, method = "POST", headers = headers, body = body, - }) - if err then return nil, "账号端点请求失败: " .. err end - local ok, rec = pcall(json.decode, resp.body or "") - if not ok or type(rec) ~= "table" then - return nil, "账号端点响应无法解析(HTTP " .. tostring(resp.status) .. ")" - end - -- 参考兼容 data 包裹与顶层两种形态 - local data = type(rec.data) == "table" and rec.data or rec - local rcode = data.rcode - if type(rcode) ~= "string" or rcode == "" then - return nil, "微信授权响应缺少 rcode(code 可能已失效)" - end - -- bind 缺失归 0(走绑定流程)—— 保守方向:已绑最多多填一次手机号; - -- 未绑却跳过会拿到没有手机号的账号。 - local bind = (data.bind == 1) and 1 or 0 - return json.encode({ - access_token = rcode, -- 后续所有步骤用 rcode(wx_code 只用这一次) - rcode = rcode, - bind = bind, - session = data.session or data.token, - user_id = data.userid or data.user_id, - nickname = data.nickname, - headpic = data.headpic, - phone_bound = bind == 1, - token_type = "Bearer", - account_type = "personal", - }), nil -end - --- deviceRegister:设备码成功后的第二跳(Cline:WorkOS token → Cline token)。 --- 参考注释强调:两步是必要的(浏览器 transient activation 约 5 秒,阻塞式 --- 等授权会让 URL 过期)。返回 (access_token, account, nil) 或 (nil, nil, err)。 -local function deviceRegister(plat, payload) - local r = plat.register - if not r or not r.url then return payload.access_token, payload.account, nil end - local body = (r.body or "") - :gsub("{access_token}", payload.access_token or "") - :gsub("{refresh_token}", payload.refresh_token or "") - local resp, err = modelrouter.http({ - url = r.url, - method = r.method or "POST", - headers = { ["Content-Type"] = "application/json", ["Accept"] = "application/json" }, - body = body, - }) - if err then return nil, nil, "token 注册失败: " .. err end - if resp.status ~= 200 then - return nil, nil, "token 注册失败(HTTP " .. tostring(resp.status) .. ")" - end - local ok, rec = pcall(json.decode, resp.body or "") - if not ok or type(rec) ~= "table" then - return nil, nil, "token 注册响应无法解析" - end - -- 响应套 {success, data} 信封(参考 parseClineTokenPayload) - local data = rec.data or rec - local access = data.accessToken or data.access_token or data.Token or data.token - if not access or access == "" then - return nil, nil, "token 注册响应缺少 accessToken" - end - local acct = "cline" - if type(data.userInfo) == "table" and (data.userInfo.email or data.userInfo.clineUserId) then - acct = tostring(data.userInfo.email or data.userInfo.clineUserId) - end - return access, acct, nil -end - --- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)。 --- --- 先查内建目录,再查运行时注入表(plugin.state.injected_platforms)。注入表的存在 --- 是因为**测试夹具绝不能写成生产源码**:此前 example/googleish/loopbackish/ --- cookieish 四个夹具直接内联在 PLATFORMS 里,于是它们真的出现在生产 UI(16 个 --- 平台里有 4 个是夹具)供运维选择。夹具属于测试,不属于发行物 —— 正确形态是 --- 测试用自己的那几行注入(见 channels/test_platforms 端点),生产目录只留真实厂商。 -local function platformById(id) - for _, p in ipairs(PLATFORMS) do - if p.id == id then return p end - end - local injected = plugin.state and plugin.state.injected_platforms - if type(injected) == "table" then - for _, p in ipairs(injected) do - if type(p) == "table" and p.id == id then return p end - end - end - return nil -end - --- allPlatforms 返回内建目录 + 注入表(列表/遍历用)。 -local function allPlatforms() - local out = {} - for _, p in ipairs(PLATFORMS) do out[#out + 1] = p end - local injected = plugin.state and plugin.state.injected_platforms - if type(injected) == "table" then - for _, p in ipairs(injected) do - if type(p) == "table" and p.id then out[#out + 1] = p end - end - end - return out -end - --- ============================================================================ --- 状态 --- ============================================================================ - --- ============================================================================ --- 插件自有持久化(账号池 / 模型黑名单 / 设置 / 日志) --- ============================================================================ --- --- 为什么插件自己落盘,而不是靠宿主框架的 plugin.state: --- 本插件声明 hooks = {},一切状态变更都走自己的 HTTP 路由。宿主的状态自动 --- flush 由 hook / Call / handler 三条分发路径触发,插件不应把「我的状态会不会 --- 被写下去」押在宿主某条路径的实现细节上 —— 那是把插件的数据安全外包给宿主。 --- 参考实现踩过同一课(channel-pack-store.ts):宿主 settings 能力一变,账号 --- 列表就退化为纯内存,必须改由插件自持文档。 --- --- 位置与凭据分离:凭据在 .secrets/(0600,跨插件不可读),这里只放**索引与 --- 配置**(账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。 --- 即便这个文件泄露,也不含任何 token。 --- --- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。 - --- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。 --- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。 -local function storeDir() - -- 显式覆盖优先(测试与多实例部署都靠它隔离,避免读到别处的生产配置) - -- 新名优先,旧名继续接受(已部署实例仍导旧变量)。 - local env = os.getenv and (os.getenv("MODELROUTER_PLUGIN_DIR") or os.getenv("LLMSPROXY_PLUGIN_DIR")) - if type(env) == "string" and env ~= "" then return env end - -- 内核交给本插件的目录(ground truth,不需要猜)。 - local kern = modelrouter.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 - -- ⚠️ 优先用内核交给我们的**本插件目录**(`modelrouter.plugin_dir`): - -- 那是 ground truth,不需要猜。此前这里直接落到"读生产 config.yaml"的兜底, - -- 在**未设插件目录环境变量的测试里**会命中生产路径 —— 测试读生产存储、 - -- 也写生产存储。一次全量 internal/lua 就把生产账号池覆盖成测试的空 pool - -- (实测:13 个账号 → 0,凭据还在保险库,症状是"账号凭空消失")。 - -- 判据:internal/lua/store_dir_isolation_test.go。 - -- 新路径优先;旧路径(改名前的部署)继续接受,避免升级后插件忽然找不到目录。 - for _, path in ipairs({ - "/etc/modelrouter/config.yaml", - "/usr/local/etc/modelrouter/config.yaml", - "/etc/llmsproxy/config.yaml", - "/usr/local/etc/llmsproxy/config.yaml", - }) do - local f = io.open(path, "r") - if f then - local txt = f:read("*a") - f:close() - if txt then - local d = txt:match("\nplugin_dir:%s*([^\n]%S*)") or txt:match("^plugin_dir:%s*([^\n]%S*)") - if d and d ~= "" then return d end - end - end - end - return "/etc/modelrouter/plugins" -end - --- persistKeys 是要落盘的字段。故意不含 sessions(登录会话是短命态,重启即失效, --- 写下去只会留下过期垃圾)与 browser(进程 pid 跨重启无意义)。 -local PERSIST_KEYS = { - "accounts", "pool", "modelDisabled", "settings", - "providerOrder", "log", "injected_platforms", - -- 远端拉到的模型目录缓存(远端为主、兜底补位的结果)。 - -- 缓存它而非每次请求都拉:上游目录是分钟级稳定,UI 却是秒级刷新。 - "remoteModels", - -- 已失效模型记录(阳性 404 证据)。必须持久化:不落盘的话重启就全忘了, - -- 用户又要重新被 404 打一遍(照参考 DEAD_MODELS_FILE 的落盘语义)。 - "deadModels", - -- 通用 TTL 缓存(余额原料等)。持久化可让重启后不立刻重打上游。 - "ttlCache", - -- 自动领取的"今天已覆盖"记账。必须持久化:不落盘的话每次重启都会 - -- 把当天已领的再领一遍(上游幂等,但会产生无谓请求与日志噪音)。 - "claimCovered", - -- 模型能力元数据(models.dev 的能力/免费/上下文)。持久化是**必须**的: - -- 冷启动时要能直接读缓存,否则首个模型列表要么空白、要么等 5MB 下载。 - "capabilities", "capabilitiesMeta", - -- 目录闸门状态(冷却/in-flight)。也落盘:重启后冷却窗口应继续有效, - -- 否则"重启即重试坏上游"(照参考把闸门状态随插件存活的语义)。 - "catalogGate", -} - -local function persistState() - local dir = storeDir() - local path = dir .. "/" .. plugin.K.STORE_NAME - local snap = {} - for _, k in ipairs(PERSIST_KEYS) do - if plugin.state[k] ~= nil then snap[k] = plugin.state[k] end - end - 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) - w:close() - os.execute(("chmod 0600 %s 2>/dev/null"):format(tmp)) - local ok = os.rename(tmp, path) - if not ok then - os.remove(tmp) - return false, "rename 失败" - end - return true -end - -local function applySnapshot(snap) - for _, k in ipairs(PERSIST_KEYS) do - if snap[k] ~= nil then plugin.state[k] = snap[k] end - end -end - --- dropLegacyKernelState 删除内核遗留文件(若存在)。 --- --- 必须删:内核的 restore() 在插件源码执行**之后**运行,会把这个文件的内容整体 --- 覆盖到 plugin.state 上。自有存储已经是权威,留下它等于给"下次启动被旧数据 --- 覆盖"留一个后门。删它不需要先读它(迁移路径自己会读)。 -local function dropLegacyKernelState() - local legacy = storeDir() .. "/.channels.state.json" - local f = io.open(legacy, "r") - if not f then return end - f:close() - os.remove(legacy) - io.stderr:write("[channels] 已删除内核遗留状态文件(自有存储为权威)\n") -end - --- loadState 读插件自有文档;缺失时**从内核遗留文件迁移一次**。 --- --- 历史背景:v2.0.4 曾让内核的 InvokeHandler 补 markDirty,于是状态被内核写到了 --- `.channels.state.json`(内核的 .state.json 命名)。现在内核改动已回退, --- 那个文件不会再被内核读写;但生产上已经存在一份(含真实账号池),必须迁移过来, --- 否则升级即丢账号 —— 与本次要修的缺陷同类,不能自己再造一次。 --- 迁移后删除遗留文件:留着它只会让「哪个文件权威」变成永久的疑团。 -local function loadOwnSnapshot() - local dir = storeDir() - local f = io.open(dir .. "/" .. plugin.K.STORE_NAME, "r") - if f then - local txt = f:read("*a") - f:close() - if txt and txt ~= "" then - local ok, rec = pcall(json.decode, txt) - if ok and type(rec) == "table" and type(rec.state) == "table" then - applySnapshot(rec.state) - return true - end - -- 损坏文件不阻塞:用内置默认值(下次写入覆盖它) - io.stderr:write("[channels] 自有存储损坏,改用默认值\n") - end - end - return false -end - --- migrateLegacyKernelState 把内核遗留的 .channels.state.json 搬进自有文档。 -local function migrateLegacyKernelState() - local dir = storeDir() - local legacy = dir .. "/.channels.state.json" - local f = io.open(legacy, "r") - if not f then return false end - local txt = f:read("*a") - f:close() - if not txt or txt == "" then return false end - local ok, rec = pcall(json.decode, txt) - if not ok or type(rec) ~= "table" or type(rec.state) ~= "table" then - return false - end - applySnapshot(rec.state) - -- 立即写成自有文档;**只有写成功才删遗留文件** —— 写失败还删它, - -- 等于用一次迁移把数据弄丢,与本次要修的缺陷同类。 - local written, werr = persistState() - if written then - os.remove(legacy) - io.stderr:write("[channels] 已迁移遗留状态到 " .. plugin.K.STORE_NAME .. "\n") - else - io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): " - .. tostring(werr) .. "\n") - end - return true -end - -local function loadState() - if loadOwnSnapshot() then - -- 自有存储已权威:遗留文件必须删掉,否则内核 restore() 会用它覆盖 - dropLegacyKernelState() - return true - end - return migrateLegacyKernelState() -end - --- markDirty = 立即同步落盘(tmp + rename 原子替换)。 --- --- 为什么不用「置标志 + 定时 flush」:本插件没有 hooks,45 个端点各自 --- 直接变更状态,还有大量内部调用(syncAccountSource / claim_daily / --- auto_checkin 互调)。把 flush 挂在某个边界上就必须保证**每一条**路径都经过 --- 它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。同步写在 --- 变更点,路径覆盖是结构性的,不依赖我数全 45 个入口。 --- --- 代价可接受:状态很小(实测 ~1.7KB),channels 没有 chat 钩子,写入只发生在 --- 运维动作(登录/领取/开关)与登录轮询(未完成时才有)上,不在请求热路径。 --- 参考实现同样是每次变更 writeFileSync + renameSync。 --- --- 失败处理:写失败**不从状态里回滚**内存变更(内存是对的),只留痕到 stderr --- ——不递归调 logEvent,那会再次触发 markDirty。 -local function markDirty() - local ok, err = persistState() - if not ok then - io.stderr:write("[channels] 状态落盘失败: " .. tostring(err) .. "\n") - end -end - -plugin.state = plugin.state or {} -plugin.state.accounts = plugin.state.accounts or {} -plugin.state.log = plugin.state.log or {} - --- 启动时从插件自有文档恢复(必须在 plugin.state 建表之后:loadState 往表里填 --- 字段,先填再被 or {} 覆盖就白填了)。 -loadState() - --- ★ 启动清扫:删掉上一进程留下的孤儿回调文件。 --- --- 为什么需要它:loopbacks 追踪表只活在内存里(plugin.state.loopbacks), --- 进程退出后无从得知上一轮起过哪些接收器。于是进程被强杀/SIGKILL 时, --- 那句 on_unload 没机会跑,磁盘上就永久留下 callback-*.port / .json。 --- 实测生产攒了 18 个孤儿 .port(对应的 .json 早已消失),且**永远不会被清** —— --- 重启后内存表是空的,reapLoopback 根本不知道它们存在。 --- --- 判据不是"文件被删了"这么弱,而是"启动后目录里不再有上一轮的残留": --- internal/core/channels_orphan_callback_test.go。 -do - local dir = storeDir() - local sh = io.popen and io.popen("rm -f " .. dir .. - "/callback-*.port " .. dir .. "/callback-*.json 2>/dev/null") - if sh then sh:close() end -end - --- utf8Clip 按**字符**截断(不是字节),并保证结果仍是合法 UTF-8。 --- --- ★ 为什么不能用 `s:sub(1, N)`:Lua 的 sub 按**字节**切,而中文字符占 3 字节 --- ⇒ 切在字符中间就产生**无效 UTF-8**,前端显示成 `能力增强不�` 这种乱码尾巴。 --- 实测(生产能力元数据那条)就是这样 —— 一句本来完整的诊断被切出替换字符, --- 看起来像数据损坏,实际是截断方式错。 --- --- 做法:先按字节切,再回退到最后一个完整 UTF-8 序列边界。 --- 只在必要时回退(最多退 2 字节),所以不会显著缩短内容。 -plugin.utf8Clip = function(s, maxBytes) - s = tostring(s or "") - if #s <= maxBytes then return s end - local cut = s:sub(1, maxBytes) - -- 回退掉尾部不完整的 UTF-8 续字节(10xxxxxx)与被切半的首字节 - for _ = 1, 4 do - local b = cut:byte(-1) - if not b then break end - if b < 0x80 then break -- ASCII,完整 - elseif b >= 0xC0 then -- 首字节但没有续字节收尾 ⇒ 整字符不完整 - cut = cut:sub(1, -2) - break - else -- 续字节,继续往前退 - cut = cut:sub(1, -2) - end - end - return cut -end - --- logEvent 写一条插件事件日志(带级别与渠道归属)。 --- --- ★ 为什么需要「级别 + 折叠」,而不是继续往一个裸数组里追加: --- 实测生产 50 条日志里 **48 条(96%)** 是三个定时任务 --- (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now) --- 的重复回音,唯一模式只有 8 种 ⇒ 约 **2 小时**就把 50 条环形缓冲冲满一轮, --- **真正的用户动作**(如「账号删除」)被挤到看不见 —— 面板沦为 --- 「最近两小时的定时任务回音」。 --- --- 三条措施: --- ① `level` ∈ info|task|warn|error。定时任务的**常规**结果记 "task" --- (前端默认折叠),只有异常才 warn/error。 --- ② **紧邻同文折叠**:与**上一条**完全同文时不新增条目,只 n=n+1 --- 并刷新时间戳。周期任务的连续回音于是合并成一条(显示 ×9), --- 而无需给每个调用点判定「这次结果变了没」(那要引入状态比较)。 --- ⚠️ 只在**紧邻**时折叠:否则一条每小时出现的消息会不断"跳"到最前, --- 时间线就失去意义。 --- ③ `platform`:记录归属,前端按当前选中渠道过滤。 --- ⚠️ 历史 state 里的老条目没有这个字段,前端必须容忍 nil。 -local function logEvent(msg, level, platform) - local log = plugin.state.log - local now = os.time and os.time() or 0 - local top = log[1] - if top and top.msg == msg and (top.level or "info") == (level or "info") then - top.n = (top.n or 1) + 1 - top.at = now - markDirty() - return - end - table.insert(log, 1, { - at = now, - msg = msg, - level = level or "info", - platform = platform, - }) - while #log > 50 do table.remove(log) end - markDirty() -end - --- accountOf 取某平台的账号记录(凭据本身在保险库,不在 state 里) -local function accountOf(id) - return plugin.state.accounts[id] -end - --- ============================================================================ --- 账号池(对齐参考 account.* RPC 契约):一个平台可挂多个账号 --- ============================================================================ --- plugin.state.pool[provider] = { [accountId] = 条目 },条目字段对齐参考 --- AccountEntry:{ id, provider, nickname, enabled, credentialRef, refreshable, --- createdAt, expiresAt }。凭据在保险库(credentialRef 即保险库键),不入 state。 --- --- 兼容:旧的单账号路径(state.accounts)保留 —— e2e 与既有端点仍走它。 -local function poolTable() - if not plugin.state.pool then plugin.state.pool = {} end - return plugin.state.pool -end - -local function poolAccounts(provider) - local t = poolTable()[provider] - if not t then t = {} poolTable()[provider] = t end - return t -end - -local function poolList(provider) - local out = {} - for _, a in pairs(poolAccounts(provider)) do out[#out+1] = a end - table.sort(out, function(x, y) - local px, py = x.priority or 0, y.priority or 0 - if px ~= py then return px > py end -- priority 大者优先(参考 reorder 语义) - local cx, cy = x.createdAt or 0, y.createdAt or 0 - if cx ~= cy then return cx < cy end - -- ★ 必须给**确定性**兜底:createdAt 是**秒级**,同一秒建的两个账号比较相等, - -- 而相等时 table.sort 的结果取决于 pairs() 的遍历顺序 —— **不确定** - -- (实测:按 a,b,c 顺序建的三个账号,poolList 回的是 [a,c,b])。 - -- 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = 内核尝试顺序, - -- 故同秒创建的账号里"先试哪个"是随机的,且可能每次进程重启都变。 - -- 用 id 作最后的稳定键(id 唯一且不变),让顺序完全确定。 - return tostring(x.id or "") < tostring(y.id or "") - end) - return out -end - -local function poolGet(provider, accountId) - return poolAccounts(provider)[accountId] -end - -local function poolAdd(provider, entry) - poolAccounts(provider)[entry.id] = entry - markDirty() -end - -local function poolRemove(provider, accountId) - poolAccounts(provider)[accountId] = nil - markDirty() -end - --- 凭据取值:保险库里存的可能是**裸 token**(历史/device-poll/paste 路径), --- 也可能是**完整凭据 JSON**( buddy / workbuddy 等 state-poll 平台,对齐参考 --- buildCredential 的结构:access_token + refresh_token + expires_at + domain + ...)。 --- --- 本助手统一取出可用的 access token(及 refresh_token),使所有 HTTP 消费点 --- (Bearer 头、HMAC 签名)无需关心存的是哪种形态: --- 裸 token → 原样返回 --- 完整凭据 JSON 字符串 → 取 .access_token --- 已经是 table → 取 .access_token --- 返回 (accessToken, refreshToken, fullTableOrNil)。两者都空时返回 (nil, nil, nil)。 -local function credentialOf(ref) - local raw = ref and modelrouter.secrets.get(ref) - if not raw or raw == "" then return nil, nil, nil end - -- 裸 token 快路:不含 '{' 的字符串直接当 token(绝大多数历史数据) - if type(raw) == "string" and raw:sub(1, 1) ~= "{" then return raw, nil, nil end - local ok, rec = pcall(json.decode, type(raw) == "string" and raw or "") - if ok and type(rec) == "table" then - local at = rec.access_token or rec.accessToken or rec.token - local rt = rec.refresh_token or rec.refreshToken - if at and at ~= "" then return at, rt, rec end - -- 是 JSON 但没有 access_token:回退原样(可能是别的形状的凭据) - return raw, rt, rec - end - return raw, nil, nil -end - --- credentialFull 取完整凭据(供需要 refresh_token/expires_at 的路径用)。 -local function credentialFull(ref) - local _, _, full = credentialOf(ref) - return full -end - --- jwtClaim 从 JWT 的 payload 读一个声明(不验签,仅用于读取展示/续期元数据)。 --- 对齐参考 buddy.ts 的 jwtNickname / jwtExpiresAtMs / jwtSubject: --- CodeBuddy 的 login/account 响应**不含昵称**,昵称只在 access_token 的声明里; --- token 端又只返回 expiresIn(相对秒数)而不含 expiresAt。两处都得从 JWT 兜底。 --- 解析失败返回 nil(调用方自行处理)。 --- b64url_decode 纯 Lua 的 base64url 解码(读 JWT payload 用)。 --- --- 为什么不用内核的 base64 能力:内核只提供 encode(适配器签名要用),没有 decode。 --- 为读一个 JWT 声明而给内核加一个通用解码函数,是把插件的事推给内核 —— --- 插件能做就该自己做。纯 Lua 实现约 20 行,无依赖、无内核改动。 --- 接受标准/URL-safe 两种字母表,容忍缺失填充。 -for i = 1, #plugin.K.B64_ALPHABET do plugin.K.B64_LOOKUP[plugin.K.B64_ALPHABET:sub(i, i)] = i - 1 end -local function b64url_decode(input) - if type(input) ~= "string" or input == "" then return nil end - -- 归一化:URL-safe → 标准,去空白,去掉填充(下面按 4 位一组补零处理) - local s = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "") - local out, acc, bits = {}, 0, 0 - for i = 1, #s do - local v = plugin.K.B64_LOOKUP[s:sub(i, i)] - if v == nil then return nil end -- 非法字符:如实失败,不猜 - acc = acc * 64 + v - bits = bits + 6 - if bits >= 8 then - bits = bits - 8 - local byte = math.floor(acc / (2 ^ bits)) % 256 - out[#out + 1] = string.char(byte) - acc = acc % (2 ^ bits) - end - end - return table.concat(out) -end - -local function jwtClaim(token, name) - if type(token) ~= "string" or token == "" then return nil end - local payload = token:match("^[^%.]+%.([^%.]+)") - if not payload then return nil end - local bin = b64url_decode(payload) - if not bin then return nil end - local ok, rec = pcall(json.decode, bin) - if not ok or type(rec) ~= "table" then return nil end - return rec[name] -end - --- stripControl 去掉控制字符(含 CR/LF/Tab)并把连续空白折叠为单个空格。 --- 对齐参考 stripControlChars:CodeBuddy 的 scope 字段有时返回多行文本, --- 原样落盘会在序列化时被当作多行标量,破坏后续 JSON 解析。 -local function stripControl(v) - if type(v) ~= "string" then return v end - v = v:gsub("[%z\1-\31\127]+", " ") - v = v:gsub("%s%s+", " ") - return (v:gsub("^%s+", ""):gsub("%s+$", "")) -end - --- readStr 从响应表安全读字符串字段(兼容后端把数字返为数字)。 -local function readStr(rec, key) - if type(rec) ~= "table" then return "" end - local v = rec[key] - if type(v) == "string" then return stripControl(v) end - if type(v) == "number" then return tostring(v) end - return "" -end - --- absExpiryMs 把绝对字段或相对秒数换算为毫秒时间戳字符串。 --- 对齐参考 absoluteExpiryMs: --- ① 绝对字段(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) - local absolute = readStr(rec, absKey) - if absolute ~= "" then - if absolute:match("^%d+$") then - local n = tonumber(absolute) - if n then - return tostring(n > 1e12 and n or n * 1000) - end - end - return absolute - end - local rel = rec[relKey] - local relSec = nil - if type(rel) == "number" then relSec = rel - elseif type(rel) == "string" and rel:match("^%-?%d+%.?%d*$") then relSec = tonumber(rel) end - if relSec == nil then return "" end - -- 基准:access_token 的 iat(签发时刻)优先,缺失时用当前时刻。 - local iat = jwtClaim(accessToken, "iat") - local baseMs = (type(iat) == "number" and iat * 1000) or (os.time and os.time() * 1000 or 0) - return tostring(baseMs + relSec * 1000) -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 --- 昵称回填顺序:account.nickname → JWT.nickname → JWT.preferred_username。 --- expires_at 同理:token.expiresAt 为空时由 absExpiryMs 用 expiresIn 或 JWT exp 兜底。 -local function buildCredential(data, accountData) - local access = readStr(data, "accessToken") - if access == "" then access = readStr(data, "access_token") end - local refresh = readStr(data, "refreshToken") - if refresh == "" then refresh = readStr(data, "refresh_token") end - local tokenType = readStr(data, "tokenType") - if tokenType == "" then tokenType = "Bearer" end - local acct = type(accountData) == "table" and accountData or {} - local nickname = readStr(acct, "nickname") - if nickname == "" then nickname = readStr(acct, "nickName") end - if nickname == "" then - local jn = jwtClaim(access, "nickname") - if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "preferred_username") end - if type(jn) ~= "string" or jn == "" then jn = jwtClaim(access, "name") end - if type(jn) == "string" then nickname = stripControl(jn) end - end - local uid = readStr(acct, "uid") - if uid == "" then - local sub = jwtClaim(access, "sub") - if type(sub) == "string" then uid = sub end - end - local acctType = readStr(acct, "type") - if acctType == "" then acctType = "personal" end - return { - access_token = access, - refresh_token = refresh, - expires_at = absExpiryMs(data, "expiresAt", "expiresIn", access), - refresh_expires_at = absExpiryMs(data, "refreshExpiresAt", "refreshExpiresIn", access), - token_type = tokenType, - scope = readStr(data, "scope"), - domain = readStr(data, "domain"), - user_id = uid, - nickname = nickname, - enterprise_id = readStr(acct, "enterpriseId"), - account_type = acctType, - } -end - --- credentialExpiresAtMs 从凭据表解析毫秒过期时刻(兼容毫秒/秒级时间戳/ISO)。 --- 对齐参考 credentialExpiresAtMs:expires_at 为空时回退解析 access_token 的 JWT exp。 -local function credentialExpiresAtMs(cred) - if type(cred) ~= "table" then return nil end - local raw = cred.expires_at - if type(raw) == "string" and raw ~= "" then - if raw:match("^%d+$") then - local n = tonumber(raw) - if n then return n > 1e12 and n or n * 1000 end - end - -- ISO 8601:解析 "YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]"。 - -- ★ 无偏移后缀时必须按 **UTC** 算,不能直接喂 os.time(它按本地时区解释)。 - -- 真实缺陷(2026-10-19):codearts 的 expires_at 是 UTC ISO - -- ("2026-10-19T16:42:43.06Z"),本机 +08:00 直接 os.time 解析 ⇒ 过期 - -- 时刻虚早 8 小时 ⇒ refresh_creds 每一轮都判"已过期"⇒ 每半小时真打一次 - -- 华为云 STS(token 本来还有 23 小时寿命)。 - -- 修法:先用 os.date("!...", epoch_of_naive_interpretation) 技巧不可靠, - -- 直接用「本地 epoch − 本地偏移」还原 UTC:os.time 表按本地解释得 t, - -- t − (os.time() − os.time(os.date("!*t"))) 即同一钟面时刻的 UTC epoch。 - -- 显式带 Z 或 ±hh:mm 后缀的按其偏移换算。 - local Y, Mo, D, H, Mi, S, rest = - raw:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)(.*)$") - if Y then - -- os.time 把字段按**本地时区**解释。对"本地钟面 = 字段值"的时刻, - -- os.time 给出的就是正确的 epoch。要做的是把"声明时区的钟面"换算成 - -- "本地钟面"再喂 os.time: - -- 字段带 Z / 无后缀 = UTC 钟面 ⇒ 本地钟面 = 字段 + localOff ⇒ - -- epoch = os.time(字段 + localOff) = naive + localOff。 - -- 字段带 ±hh[:mm] = 声明时区钟面 ⇒ 本地钟面 = 字段 + localOff − off。 - local naive = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D), - hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) - local localOff = os.time() - os.time(os.date("!*t")) - if rest:sub(1, 1) == "+" or rest:sub(1, 1) == "-" then - -- 偏移形如 "+08:00" / "-05:00" / "+0800"。字符类 [%+%-] 在部分 Lua - -- 构建下不可靠(实测 luajit 匹配 nil),用 sub 拆更稳。 - local sign = rest:sub(1, 1) - local oh, om = rest:sub(2):match("^(%d%d):?(%d*)") - local off = (tonumber(oh) or 0) * 3600 - + (tonumber(om ~= "" and om or "0") or 0) * 60 - if sign == "-" then off = -off end - -- 字段是"声明时区 off 的钟面"。本地钟面 = 字段 + (localOff − off): - -- 例(本机 +08):字段 "11:42:43-05:00"(=16:42:43Z)的本地钟面是 - -- 00:42:43(+1d);localOff−off = 28800−(−18000) = 46800 = 13h, - -- 11:42:43 + 13h = 00:42:43(+1d)。✓ - return (naive + (localOff - off)) * 1000 - end - return (naive + localOff) * 1000 - end - end - local exp = jwtClaim(cred.access_token, "exp") - if type(exp) == "number" then return exp * 1000 end - return nil -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)。失败原因必须可诊断(不能只说"失败了")。 --- --- 两种续期方式: --- * `plat.refresh`(声明式):普通 JSON/form OAuth —— 大多数平台 --- * `plat.refresh_fn`(钩子):需要签名的(华为云 STS 要 DPoP 头, --- 而声明式 style 拿不到私钥) --- 钩子签名:refresh_fn(plat, cred) -> (newCredTable, nil) | (nil, err) --- 钩子自己负责写密文(因为写入路径只有一个:modelrouter.secrets.set)。 -local function refreshCredential(plat, acct) - local cred = credentialFull(acct.credentialRef) - if type(cred) ~= "table" then return false, "凭据无法解析" end - -- ★ 钩子优先:需要签名的平台无法用声明式 style 表达 - -- (DPoP 私钥不在 style 的可见范围内)。 - if type(plat.refresh_fn) == "function" then -if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end -local newCred, ferr = plat.refresh_fn(plat, cred) -if not newCred then return false, tostring(ferr or "续期失败") end -modelrouter.secrets.set(acct.credentialRef, json.encode(newCred)) -if newCred.expires_at then -local ms = credentialExpiresAtMs(newCred) -if ms then acct.expiresAt = math.floor(ms / 1000) end -end -return true, "已续期" - end - local r = plat.refresh - if type(r) ~= "table" or not r.url 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 = modelrouter.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 - modelrouter.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 - -- models_unavailable:**声明上就不可调用**的条目(不是"暂不显示")。 - -- 与 models_hidden 的区别:hidden 只是不进 UI 列表(内部子 agent 仍可被 - -- 显式调用),unavailable 连**上游源**都不进 —— 因为它们调用必然失败/回空, - -- 进源只会让客户端选中后报错(trae 的 custom_model_* 槽位就是这类)。 - local unavailable = {} - for _, m in ipairs(plat.models_unavailable or {}) do unavailable[m] = true end - -- ★ admit_functions:只放行**本适配器真的会发**的通道里的模型。 - -- - -- 白名单(parse 里的 rankOf)只说明"该通道在本端点下可调用",不等于 - -- "适配器会发它"。trae 适配器恒发 solo_work_lite,别的通道的模型经我们 - -- 这条路发出去要么回空内容、要么被上游悄悄回落到默认模型(实测 - -- kimi-k2.8-preview / deepseek-V3 空回,sagitta 自称 Claude、aquila 自称 - -- GPT)—— 那正是用户说的"模型不正确"。 - -- - -- 放在这里而不是解析器里,是因为解析器还要负责**元数据合并**:同一模型在 - -- solo_agent 里有思考档位、在 solo_work_lite 里没有,合并必须跨通道进行 - -- (否则模型显示"未提供推理等级")。策略只决定"哪些 id 对外露出"。 - local admit = nil - local mrAdmit = plat.models_remote and plat.models_remote.admit_functions - if type(mrAdmit) == "table" then - admit = {} - for _, f in ipairs(mrAdmit) do admit[f] = true end - 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 - -- 兜底补位(只补远端没有的)。 - -- ⚠️ plat.models_fallback_no_supplement=true 的平台**不补**:远端目录可用时 - -- 它就是权威,兜底表只是"拉不到时的替代",补进来会把已下架/错映射的名字 - -- 重新塞进列表(见该字段注释里的实测)。 - local haveRemote = type(remote) == "table" and type(remote.ids) == "table" and #remote.ids > 0 - if not (plat.models_fallback_no_supplement and haveRemote) then - for _, id in ipairs(plat.models or {}) do - if not seen[id] then seen[id] = true out[#out+1] = id end - end - end - -- 剔除**有阳性证据**已失效的模型(照参考 dead-model-store 的剔除语义)。 - -- ⚠️ 只剔这里记录过的(真正 404 过的),**绝不**拿远端目录反推下架 —— - -- buddy/qoder 那类平台的远端目录残缺,反推会删掉可用模型。 - local dead = deadModelIds(provider) - local metaAll = plugin.state.modelMeta and plugin.state.modelMeta[provider] or {} - local kept = {} - for _, id in ipairs(out) do - local keep = (not dead[id]) and (not unavailable[id]) - if keep and admit then - local e = metaAll[id] - -- 有目录元数据 ⇒ 按它声明的通道判;没有元数据(兜底/手工源)⇒ 不拦, - -- 因为"目录没说"不等于"不可调用"。 - if type(e) == "table" and type(e.chans) == "table" then - keep = false - for _, c in ipairs(e.chans) do - if admit[c] then keep = true break end - end - elseif type(e) == "table" and type(e.chan) == "string" and e.chan ~= "" then - keep = admit[e.chan] == true - end - end - if keep then kept[#kept+1] = id end - end - return kept -end - --- remoteFreeSets:provider → { [modelId]=true },来自远端**权威** free 数组。 --- 照参考:`recommended-models` 的 free 数组是唯一权威的免费集合, --- `/models` 的 id 列表里根本没有 cline-free/*。 --- 只收 id 不记"它免费"会丢掉这层信息 ⇒ 免费模型被标成收费。 -remoteFreeSets = remoteFreeSets or {} - --- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。 --- 每个分支的字段路径都照参考实现,含注释点名的坑。 -local function parseRemoteModels(kind, body, provider) - 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),因为该端点历史上变过形状。 - -- - -- ★★ 必须过滤「不可调用」的条目 —— 远端 data.models 里混着三类**不是对话 - -- 模型**的东西,列进模型选择器只会让用户选中后报错(实测生产:glm-5.1 - -- 与 glm-5.2 之外,codewise-* / nes-gf / hunyuan-image-alpha 同样调不通)。 - -- 判据与命名逐条照参考 buddy.ts 的 isChatModel / isAutoSelectAlias: - -- ① 自动选择类内部别名:`auto` / `default` - -- ⚠️ 只按**这两个字面量**过滤,不做前缀匹配 —— 前缀会误伤 - -- `default-model` / `fast-model`(国际版抽象别名,但被 craft agent - -- 引用,是官方推荐入口)。 - -- ② 补全/NES 专用:前缀 `nes-` / `completion-` / `codewise-`, - -- 或带 `supportsExtra` 标记。参考实测 codewise-default-model-v2 - -- 返回 `code 11102 model service info not found`(后端未开放)。 - -- ③ 输出上限过小(>0 且 ≤256):补全用途,对话模型普遍 ≥24000。 - -- ④ 带 `text-to-image` 标签的生成式模型(如 hunyuan-image-alpha)。 - -- ⚠️ ③④ 需要元数据,故先把 models 建成 id→meta 映射。 - local d = rec.data or rec - local arr = d.models or d.agents or rec.models - local metaById = {} - if type(arr) == "table" then - for _, m in ipairs(arr) do - if type(m) == "table" and type(m.id) == "string" and m.id ~= "" then - metaById[m.id] = m - end - end - end - -- 被 agent 引用的模型(craft/ask/plan/cli)。参考据此给 `agentReferenced` - -- 打标:`hy4-preview-f` 只由本端点下发且被 agent 引用,白名单重建会丢掉它。 - local agentRef = {} - if type(d.agents) == "table" then - for _, ag in ipairs(d.agents) do - if type(ag) == "table" and type(ag.models) == "table" then - for _, mid in ipairs(ag.models) do - if type(mid) == "string" then agentRef[mid] = true end - end - end - end - end - -- ★ 已实测**不可调用**的模型(上游恒回 11102 "model service info not found")。 - -- - -- 为什么需要它:这类模型既**不是**前缀可判(不像 nes-/codewise-),也**不在** - -- agent 引用清单之外 —— 实测 `hy4-preview-x` 被服务端 agents 引用, - -- 于是会被下面的 agentRef 例外重新放回目录,用户选中就 400。 - -- 参考的做法是**白名单重建**(只保留编译期实测过的 id),本仓没有那份表, - -- 故用等价的**黑名单**精确排除:只放实测确认调不通的,不做任何猜测。 - -- - -- ⚠️ 判据来源必须是**真实请求**,不是元数据: - -- 实测 hy4-preview-x 加/不加 reasoning_effort 都是 11102 ⇒ 与 effort 无关, - -- 是后端未开放(与 glm-5.1 的 11150 是**不同**原因,后者已被 adapter 修好)。 - -- 参考 product.ts 的兜底白名单里同样**没有** hy4-preview-x。 - local NOT_CALLABLE = { - ["hy4-preview-x"] = true, - } - local function buddyIsChatModel(id) - if NOT_CALLABLE[id] then return false end - if id == "auto" or id == "default" then return false end - if id:sub(1, 4) == "nes-" or id:sub(1, 11) == "completion-" - or id:sub(1, 9) == "codewise-" then return false end - local m = metaById[id] - if type(m) == "table" then - if m.supportsExtra == true then return false end - local mo = tonumber(m.maxOutputTokens) - if mo and mo > 0 and mo <= 256 then return false end - if type(m.tags) == "table" then - for _, t in ipairs(m.tags) do - if t == "text-to-image" then return false end - end - end - end - return true - end - local seen = {} - local function pushId(id) - if type(id) ~= "string" or id == "" then return end - if seen[id] or not buddyIsChatModel(id) then return end - seen[id] = true - out[#out + 1] = id - end - -- models 与 agents 两处都收(agents 里是被引用的清单,可能含远端 - -- data.models 没有的新变体)。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) or nil - if type(id) == "string" then pushId(id) end - end - end - -- agentRef 的例外也必须过 NOT_CALLABLE 闸:否则"被 agent 引用"会把 - -- 实测不可调的模型重新放回目录(hy4-preview-x 正是这样漏进来的)。 - for id in pairs(agentRef) do pushId(id) end - - - elseif kind == "codearts_builtin" then - -- 照参考 fetchCodeArtsRemoteModels 的第 2 个端点:字段是 builtinModels - -- (**对象**,键为序号;用 pairs 兼容对象/数组两形态)。 - local raw = rec.builtinModels or (rec.data and rec.data.builtinModels) - if type(raw) == "table" then - for _, m in pairs(raw) do - local id = type(m) == "table" and (m.model_id or m.modelId or m.id) - if type(id) == "string" and id ~= "" then out[#out+1] = id end - end - end - - elseif kind == "codearts_gateway" then - -- 照参考的第 1 个端点:result.models(benefit 模型)。 - local arr = rec.result and rec.result.models - if type(arr) == "table" then - for _, m in ipairs(arr) do - local id = type(m) == "table" and (m.model_id or m.modelId or m.id) - if type(id) == "string" and id ~= "" then out[#out+1] = id end - end - end - - elseif kind == "trae_batch" then - -- ★ TRAE 的 batch_get_detail_param(多通道模型目录)。 - -- - -- 参考实现(parseTraeBatchModelList)记录了**三个真实缺陷**,移植时必须一起 - -- 带上,否则等于把已修好的 bug 再引进一遍: - -- - -- ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7): - -- 22 个 function 里有一半在本插件推理端点下**不可调用** - -- (实测 chat→4023 model unknown、builder→4001、inline_chat→3003)。 - -- 旧实现只把白名单用于"同模型择优",于是"目录声明了什么就发什么" —— - -- 用户选中那些模型必然失败,而错误文案指向**模型**,极易被误判成 - -- "这个模型坏了"。现在不在表内的通道**整组丢弃**。 - -- - -- ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7): - -- 旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据 - -- **相反** —— 上游把空档位的 solo_work_lite/solo_design_remote 排在最后, - -- 实测 13 个模型因此丢掉 reasoning_effort_config,全部显示"未提供推理等级"。 - -- - -- ③ **is_custom_model 是「仅可见但不可调用」的权威判据**: - -- 该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。 - -- ⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志会随服务端下发变化 - -- (某次快照里 5 个条目全部失效、全目录 true 的条目数为 0)。 - -- - -- 另有硬性过滤:usage 必须 chat_completion、config_switch 不得 false、 - -- is_invisible_to_user 不得 true。 - local TRAE_CHANNELS = { - "solo_agent", "solo_work_lite", "solo_agent_remote", "solo_work_remote", - "solo_agent_lite", - "chat_v3", "builder_v3", "solo_coder", "solo_design_lite", - "solo_design_remote", "git_ai", "code_reviewer", "code_review_summary", - "multimodal", "system_diagnosis", - } - local rankOf = {} - for i, c in ipairs(TRAE_CHANNELS) do rankOf[c] = i end - - -- 取布尔字段(两种大小写) - local function rbool(t, a, b) - local v = t[a] - if v == nil and b then v = t[b] end - return v - end - local function rstr(t, a, b) - local v = t[a] - if v == nil and b then v = t[b] end - return type(v) == "string" and v or nil - end - local function rnum(t, a, b) - local v = t[a] - if v == nil and b then v = t[b] end - return tonumber(v) - end - - -- declaresEffort(entry):该条目**能否真正声明出**思考档位。 - -- ⚠ 必须与适配器的档位判定完全一致(配置存在 + 未显式 support_thinking:false - -- + options 非空)。只判"配置存在"不够:{support_thinking:false, options:[]} - -- 与 {...options:['high']} 都"存在配置",但前者在 UI 上仍显示"未提供推理等级" - -- —— 按"存在即优先"合并等于没修。 - local function declaresEffort(e) - -- ⚠ 字段名必须与 parseEntry 存的一致(e.reasoning)。写成 e.reasoning_effort_config - -- 会让整个判定**恒为 false** ⇒ 规则①永不生效 ⇒ 空档位照样覆盖有档位 - -- (正是该规则要防的缺陷;判据当场抓到)。 - local rc = e.reasoning - if type(rc) ~= "table" then return false end - local st = rbool(rc, "support_thinking", "supportThinking") - if st == false then return false end - local opts = rc.options or rc.Options - return type(opts) == "table" and #opts > 0 - end - - -- parseEntry(it, channel):把一条 config_info_list 条目解成我们关心的字段。 - local function parseEntry(it, chan) - local id = rstr(it, "config_name", "ConfigName") - if not id or id == "" then return nil end - local disp = it.display_config or it.DisplayConfig - if type(disp) ~= "table" then disp = {} end - local e = { - id = id, - name = rstr(disp, "display_name", "displayName") or id, - chan = chan, - usage = rstr(it, "usage", "Usage"), - enabled = rbool(it, "config_switch", "ConfigSwitch"), - hidden = rbool(it, "is_invisible_to_user", "IsInvisibleToUser"), - custom = rbool(disp, "is_custom_model", "IsCustomModel"), - reasoning = it.reasoning_effort_config or it.reasoningEffortConfig, - } - -- ⚠ contextWindow 取 `dev` 而非 `max`:真实条目形如 {dev:200000, max:1000000}, - -- 而 max 需开启 display_config.max_mode 才可用(我们不实现该开关)。 - -- 采信 max 会让上层以为有 1M 窗口、实际请求被上游拒。 - local cw = it.context_window_tokens or it.contextWindowTokens - if type(cw) == "table" then - e.contextWindow = rnum(cw, "dev", "Dev") - end - -- 输出上限:model_detail_list[].max_tokens(取首条的 max_tokens) - local mdl = it.model_detail_list or it.modelDetailList - if type(mdl) == "table" and type(mdl[1]) == "table" then - e.maxOutputTokens = rnum(mdl[1], "max_tokens", "maxTokens") - end - -- 计费:display_contact_config 是 **JSON 字符串**(需二次解析) - local dcc = it.display_contact_config or it.DisplayContactConfig - if type(dcc) == "string" and dcc ~= "" then - local okj, cfg = pcall(json.decode, dcc) - if okj and type(cfg) == "table" then - local rate = cfg.consumption_rate or cfg.ConsumptionRate - if type(rate) == "table" then - local enable = rbool(rate, "enable", "Enable") - if enable ~= false then - local data = rate.data or rate.Data - if type(data) == "table" then - local v = rnum(data, "rate", "Rate") - if v and v >= 0 then e.creditsRate = v end - end - end - end - -- 活动折扣:**不能只看 enable** —— 实测 enable=true 但当前无折扣的形态 - -- 形如 {type:"none", before:0.13, after:0.13, discount:100} - -- (discount=100 表示无折扣,before==after)。照显会让用户以为有活动。 - local disc = cfg.activity_discount or cfg.ActivityDiscount - if type(disc) == "table" and rbool(disc, "enable", "Enable") ~= false then - local ddata = disc.data or disc.Data - if type(ddata) == "table" then - local cur = ddata.current or ddata.Current - if type(cur) == "table" then - local dtype = (rstr(cur, "discount_type", "discountType") or "") - dtype = dtype:lower():gsub("^%s+", ""):gsub("%s+$", "") - if dtype ~= "" and dtype ~= "none" then - local before = rnum(cur, "before_consumption_rate", "beforeConsumptionRate") - local after = rnum(cur, "consumption_rate", "consumptionRate") - -- 必须是真的降价:before 有值、为正、且严格大于 after - if before and before > 0 and (not after or before > after) then - -- 截止时间:data 下任意带 end_at 的子对象 - local endsAt - for _, v in pairs(ddata) do - if type(v) == "table" then - local endv = rnum(v, "end_at", "endAt") - if endv and endv > 0 then endsAt = endv break end - end - end - local nowS = os.time and os.time() or 0 - if not endsAt or endsAt > nowS then - e.origCreditsRate = before - e.discountEndsAt = endsAt - end - end - end - end - end - end - end - end - return e - end - - local groups = rec.function_configs or rec.functionConfigs - if type(groups) == "table" then - local byId, chosenRank, order = {}, {}, {} - -- chansOf[id]:该 id 在**哪些通道**里出现过(合并只留一条元数据,但 - -- "在哪些通道可发"是策略判定要用的信息 —— admit_functions 不能只看 - -- 合并后那条的 chan:同模型常在有档位的 solo_agent 与我们要发的 - -- solo_work_lite 同时出现,合并会选前者,导致误判"不可发")。 - local chansOf = {} - for _, g in ipairs(groups) do - if type(g) == "table" then - local chan = rstr(g, "function", "Function") - -- 准入:未声明 function 或不在白名单 → 整组丢弃 - -- - -- ⚠️ 「白名单」只回答"该通道在本适配器的推理端点下**可调用**"; - -- "适配器会不会发这条通道"是另一回事,由 effectiveModels 的 - -- models_remote.admit_functions 过滤(那是产品策略,不该写进解析器: - -- 解析器的职责是把目录如实解出来,含元数据合并)。 - if chan and rankOf[chan] then - local list = g.config_info_list or g.ConfigInfoList - if type(list) == "table" then - for _, it in ipairs(list) do - if type(it) == "table" then - local e = parseEntry(it, chan) - if e then - -- 硬性过滤(不设开关) - local ok = true - if e.usage ~= nil and tostring(e.usage) ~= "chat_completion" then ok = false end - if e.enabled == false then ok = false end - if e.hidden == true then ok = false end - -- is_custom_model: 仅可见但不可调用(上游流内 4001 拒绝) - if e.custom == true then ok = false end - if ok then - chansOf[e.id] = chansOf[e.id] or {} - chansOf[e.id][chan] = true - local inc = byId[e.id] - if inc == nil then - byId[e.id] = e - chosenRank[e.id] = rankOf[chan] - order[#order + 1] = e.id - else - local incEff = declaresEffort(inc) - local candEff = declaresEffort(e) - local take = true - if incEff and not candEff then - take = false -- 规则①:空档位不得覆盖有档位 - elseif incEff and candEff then - -- 规则②:两侧都有档位 → 取通道优先级更靠前的 - take = rankOf[chan] < (chosenRank[e.id] or 0) - end - if take then - byId[e.id] = e - chosenRank[e.id] = rankOf[chan] - end - end - end - end - end - end - end - end - end - end - for _, id in ipairs(order) do - out[#out + 1] = id - -- 元数据另存(model_list 用它报出上下文窗口/档位/倍率): - -- ⚠ 只报 id 会把参考能提供的元数据全部丢掉(档位信息在解析阶段就没了, - -- 上层只能显示"未提供推理等级")。 - if not plugin.state.modelMeta then plugin.state.modelMeta = {} end - plugin.state.modelMeta[provider] = plugin.state.modelMeta[provider] or {} - local entry = byId[id] - if type(entry) == "table" and chansOf[id] then - local list = {} - for c in pairs(chansOf[id]) do list[#list+1] = c end - table.sort(list) - entry.chans = list - end - plugin.state.modelMeta[provider][id] = entry - 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) - -- ★ 同时把 `free` 数组记成**权威免费集合**(照参考 cline-models.ts: - -- `recommended-models` 的 free 数组是唯一权威来源)。 - -- 只收 id 不记"它免费"会丢掉这层信息 —— 表现为免费模型被标成收费, - -- 用户不知道哪个可以不花钱用。 - if type(rec.free) == "table" then - local fr = {} - for _, m in ipairs(rec.free) do - local id = type(m) == "table" and m.id or (type(m) == "string" and m) - if type(id) == "string" and id ~= "" then fr[id] = true end - end - remoteFreeSets = remoteFreeSets or {} - remoteFreeSets[provider] = fr - end - end - - return out -end - --- refreshRemoteModels(provider):拉取并缓存该平台的远端目录。 --- 返回 (ids, err);**任何失败都不清空既有缓存**(宁可留旧目录也别让平台变空)。 -local function refreshRemoteModels(provider) - local plat = platformById(provider) - if not plat then return nil, "未知平台" end - -- 最近一次目录响应的**原始体**:解析失败时用来区分"网络/HTTP 故障"与 - -- "服务端 200 但账号侧没有可下发配置"(后者文案完全不同,见函数尾部)。 - local lastBody = nil - 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" } - -- ★ ep.body:POST 型目录端点(如 trae 的 batch_get_detail_param 必须 POST + - -- 指定请求体)。缺失时按 GET。 - local method = ep.method or "GET" - if ep.body then headers["Content-Type"] = "application/json" end - 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 - -- ★ 签名端点:codearts(华为 snap-access)的目录**必须 AK/SK 签名**, - -- 不是 Bearer。ep.sign = "codearts" 时用平台签名函数重建整个请求 - -- (签名覆盖 host/x-sdk-date/x-sdk-content-sha256/x-security-token 等头, - -- 故不能「先拼头再签名」—— 必须由签名函数产出最终请求)。 - local req = { url = url, method = method, headers = headers } - if ep.body then req.body = ep.body end - if ep.sign == "trae_solo" then - -- TRAE 的鉴权是 Cloud-IDE-JWT + 一组客户端头 —— 不能只发 Bearer。 - -- ⚠️ 必须用 **IDE 头**(traeIdeHeaders),不能用签到头: - -- 签到头是 `api.trae.cn` 的 UG/积分形态(VSCode UA + X-Market-*), - -- 发到 IDE 目录端点会被逐 function 判 2001(见 traeIdeHeaders 的注释)。 - local cred - for _, a in ipairs(poolList(provider)) do - if a.enabled ~= false then - local raw = modelrouter.secrets.get(a.credentialRef) - local okj, cj = pcall(json.decode, raw or "") - if okj and type(cj) == "table" and cj.access_token then - cred = cj break - end - end - end - if not cred then - lastErr = "无可用 TRAE 凭据" - elseif type(traeIdeHeaders) ~= "function" then - lastErr = "traeIdeHeaders 未就绪(前向引用顺序问题)" - else - -- ★ 目录是 **IDE 端点**,必须用 IDE 头(不是签到头)。 - -- 2026-10-20 事故:这里曾复用 traeCheckinHeaders ⇒ 22 个 function 全回 - -- 2001 "Config detail ... not found" ⇒ 永远拉不到目录。详见 traeIdeHeaders。 - local h, herr = traeIdeHeaders(plat, cred, "application/json") - if herr then - lastErr = tostring(herr) - else - -- 融平台头(后者覆盖前面的 Accept,但保留 Authorization 等) - for k, v in pairs(h or {}) do req.headers[k] = v end - end - end - end - if ep.sign == "codearts" then - local cred - for _, a in ipairs(poolList(provider)) do - if a.enabled ~= false then - local raw = modelrouter.secrets.get(a.credentialRef) - local okj, cj = pcall(json.decode, raw or "") - if okj and type(cj) == "table" and (cj.access_key_id or cj.access_key) then - cred = cj break - end - end - end - if not cred then - lastErr = "无可用 AK/SK 凭据" - else - local sreq, serr = codeartsSignedRequest(req.method, url, cred, nil) - if sreq then - -- 目录端点额外要求的**非签名**头(照参考 fetchSignedGet 调用点) - for k, v in pairs(ep.headers or {}) do sreq.headers[k] = v end - req = sreq - else - lastErr = tostring(serr) - end - end - end - local resp, err = modelrouter.http(req) - if err or not resp or resp.status ~= 200 then - lastErr = (err or ("HTTP " .. tostring(resp and resp.status))) - else - lastBody = resp.body or "" - for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body, provider)) do - if not seen[id] then seen[id] = true merged[#merged+1] = id end - end - end - end - if #merged == 0 then - -- ★ 把"空目录"再细分一层:远程 200 但一条配置都没有,通常不是网络问题, - -- 而是**账号侧**没有可下发的 function 配置(企业租户账号实测:22 个 - -- function 全回 error_code 2001 "Config detail for function: X not found")。 - -- 这两类原因的处置完全不同(网络重试 vs 找租户管理员),日志不能都说 - -- "远端返回空目录"。判据只看响应体里出现 2001 或 function_configs。 - if not lastErr then - local seenBody = false - if type(lastBody) == "string" and lastBody:find("function_configs", 1, true) then - seenBody = true - end - if seenBody then - return nil, "远端 200 但无模型配置(账号侧未下发 function config," - .. "企业/租户账号需管理员开启,实测 error_code 2001)" - end - end - 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] - if not m then m = {} plugin.state.modelDisabled[provider] = m end - return m -end - --- setModelDisabled 是**唯一**的模型黑名单写入口:写完即落盘。 --- 之所以收成一个函数:黑名单的写点曾有 5+ 处(单个开关/批量/清死模型/供应商 --- 整体开关),散着写必然漏标脏。收口后只有这里需要保证。 -local function setModelDisabled(provider, modelId, disabled) - local m = disabledModelsOf(provider) - if disabled then m[modelId] = true else m[modelId] = nil end - markDirty() -end - --- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。 -local CHANNEL_CREDITS = { - codearts = { claim = 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-credits.ts:claimRaccoonLoginReward。此前我们声明为空表 → 白丢一个 - -- 可领渠道。幂等靠响应的 `granted` 字段(重复领取同样 HTTP 200 但 granted=false)。 - raccoon = { claim = true, unit = "points" }, - minimax = { claim = true, status = true }, - -- zcode 有签到,但每次领取都要现场产**阿里云 captcha**(headful Chromium, - -- 且阿里云按设备限流 150 次/小时)。故照参考 isAutoCheckinExcluded: - -- **自动**签到跳过它(代价发生在调用期间,等它报错来不及); - -- 用户仍可**手动**点领取 —— 浏览器弹出来是用户能理解的交互。 - zcode = { claim = true, unit = "tokens", auto_exclude = true }, - gemini = { unit = "quota", quota = true }, -} - --- ---- 账号级 source 同步 ---- --- 每个账号一条上游源:channels--。模型开关即改写 --- 这条源的 models 列表(remove+add 重建;内核 add_source 同名覆盖,但显式 --- 先 remove 更诚实,避免旧模型残留)。 --- --- ★ 现在**一个通道 = 一个适配器 = 一个源**,源名只跟通道绑定。 --- 旧设计是「一账号一源」(channels--),它有三个 --- 结构性后果,全都在生产里出现过: --- 1. 三个 trae 账号 ⇒ 三个源、三份同名模型。用户在模型下拉框里看到三行 --- 无法区分的东西(channels-trae-aaa · glm-5.2 / ...bbb · glm-5.2)。 --- 2. 谁先谁后**取决于注册顺序,不是余额**。余额用完的号可能排在前面, --- 每次都先撞它再回退。 --- 3. 换号要改源名 ⇒ 用户书签/脚本里的 `源:模型` 全部失效。 --- 新设计下换号 = 用同名 add_source 覆盖 api_key(内核 AddSource 是 --- UpsertSourceInYAML + 内存替换),对客户端完全透明。 -local function providerSourceName(provider) - return "channels-" .. provider -end - --- legacy 源名(旧版按账号拆源的产物)。保留只为了迁移时能删掉旧源。 -local function accountSourceName(provider, accountId) - return "channels-" .. provider .. "-" .. accountId -end - --- syncProviderSource(provider):把该通道的**唯一**上游源与模型目录对齐。 --- --- ★ 这是「一个通道 = 一个适配器 = 一个源」的落点。换号完全在插件内部完成, --- 内核只看到一个源(同名 upsert),客户端完全无感。 --- --- 幂等:可在任何“该用哪个号可能变了”的时刻重复调(账号增删改 / 余额刷新 / --- 领取完成 / 续期成功 / 30 分钟心跳巡检)。 --- pickAccountForSource(plat):从该平台的账号池里挑一个**现在能用**的号。 --- --- 判据顺序(每一级都是真实故障的产物): --- 1. 凭据读得出来(占位号 / 密文损坏的号直接跳过) --- 2. 余额可查且有余量(与 rankAccounts 同口径:先用到快作废的,再用永久的) --- • 没有 balance 声明的平台不据此排除 —— 不能因为"我们不会查"就把可用号判死 --- • 查询失败同理归入"未知",保留可用性 --- 3. 排除已打失效标记的号(request_end 钩子根据**真实请求失败**打的) --- --- 返回 (acct, reason)。reason 用于日志,让"为什么选它"可复查。 -pickAccountForSource = function(plat) - local accounts = poolList(plat.id) - if #accounts == 0 then return nil, "无账号" end - - local withCred = {} - for _, a in ipairs(accounts) do - if a.enabled ~= false then - local tok - if plat.credential_shape == "full" then - tok = modelrouter.secrets.get(a.credentialRef) - else - tok = credentialOf(a.credentialRef) - end - if tok ~= nil and tok ~= "" then withCred[#withCred + 1] = a end - end - end - if #withCred == 0 then return nil, "无可用凭据(占位账号或密文不可读)" end - - local ranked = withCred - if plat.balance then - local allowPermanent = not (plugin.state.settings - and plugin.state.settings.lock_permanent_credits) - local expiring, permanent, unknown = {}, {}, {} - for _, a in ipairs(withCred) do - local raw = accountBalanceRaw(plat.id, a.id) - if raw == nil then - unknown[#unknown + 1] = a -- 查不到 ⇒ 不据此排除 - else - local tier = balanceTier(raw.expiring_balance or raw.expiringBalance, - raw.permanent_balance or raw.permanentBalance, allowPermanent) - if tier == "expiring" then expiring[#expiring + 1] = a - elseif tier == "permanent" then permanent[#permanent + 1] = a - else unknown[#unknown + 1] = a end - end - end - ranked = {} - for _, a in ipairs(expiring) do ranked[#ranked + 1] = a end - for _, a in ipairs(permanent) do ranked[#ranked + 1] = a end - for _, a in ipairs(unknown) do ranked[#ranked + 1] = a end - if #ranked == 0 then return nil, "所有账号余额均不可用" end - end - - for _, a in ipairs(ranked) do - if not a.termErr then - return a, "选中账号 " .. tostring(a.id) - .. (a.nickname and a.nickname ~= "" and ("(" .. a.nickname .. ")") or "") - end - end - return ranked[1], "所有账号均标记失效,回退到 " .. tostring(ranked[1].id) -end -local function syncProviderSource(provider) - local plat = platformById(provider) - if not plat then return end - -- ★ 自动选号:凭据可用 + 余额分档 + 跳过已标失效的号。 - -- 判据全部在插件内(内核不知道账号概念)。 - local acct, why = pickAccountForSource(plat) - if not acct then - -- 没有可用账号 ⇒ **撤掉源**。留着旧源会让模型继续出现在列表里却每次都 - -- 失败(用户无法区分"我没配"和"配了但坏了")。 - local gone = providerSourceName(provider) - modelrouter.kernel.call("remove_source", { name = gone }) - if plugin.state.chosen then plugin.state.chosen[provider] = nil end - logEvent("通道 " .. provider .. " 无可用账号,已撤源:" .. tostring(why), "warn", provider) - 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 = modelrouter.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 - logEvent("通道 " .. provider .. " 凭据读取失败,跳过同步", "warn", provider) - return - end - -- ★ 记录"当前为该通道服务的账号"。request_end 钩子靠它把真实请求的 - -- 失败原因归因到具体账号(否则只能知道“这个通道坏了”,不知道是哪个号)。 - plugin.state.chosen = plugin.state.chosen or {} - plugin.state.chosen[provider] = acct.id - local name = providerSourceName(provider) - local models = {} - local disabled = disabledModelsOf(provider) - -- 用**有效目录**(远端优先、兜底补位),不是硬编码表 - 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。 - -- ⚠ 这里失败**可容忍**:AddSource 是**同名 upsert**,add 会把旧的覆盖掉。 - -- 但这正是它掩盖 remove_source 权限缺失的原因(纯删除路径才暴露), - -- 故失败仍记一笔(低频,不会刷屏)。 - local rr, rerr = modelrouter.kernel.call("remove_source", { name = name }) - if rr == nil or rr.ok ~= true then - local l = plugin.state.log - local last = type(l) == "table" and l[1] and l[1].msg or "" - -- 只在**上一次不是同一条**时记,避免每次同步都刷一条 - if not tostring(last):find("撤源(重建)失败", 1, true) then - logEvent("撤源(重建)失败 " .. tostring(name) .. ": " - .. tostring(rerr or "(无错误信息)"), "warn", provider) - end - end - -- ★ 双区:buddy 的 chat host 按所选账号的区域取(WorkBuddy=www.workbuddy.ai)。 - -- 选号在上方完成(plugin.state.chosen[provider] 已记录),从其凭据读 region。 - local chatBase = plat.base_url - if provider == "buddy" and plugin.state.chosen and plugin.state.chosen[provider] then - local chosenCred = credentialFull( - (poolGet(provider, plugin.state.chosen[provider]) or {}).credentialRef) - if type(chosenCred) == "table" and chosenCred.region == "intl" then - chatBase = "https://" .. plugin.K.BUDDY_REGION_HOST.intl - end - end - local src = { - name = name, - base_url = chatBase, - 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 - -- 声明式上游重试策略(见平台上的 retry_policy 注释):内核只按 status + - -- body 子串匹配,不认识任何厂商。 - if type(plat.retry_policy) == "table" then src.retry = plat.retry_policy end - -- ★ 把**设备身份**随源带给适配器:有些平台的头不是简单 Bearer, - -- 需要 uid / device_id / machine_id 派生(trae 的 X-Uid / X-Device-Id / - -- X-Machine-Id)。适配器沙箱里**读不到凭据**,只能靠 meta 传。 - -- - -- 真实缺陷(2026-10-17 用户报「无法使用 channel 注册的模型」): - -- 此前 meta 为空 ⇒ 适配器没法构造 Cloud-IDE-JWT 头族 ⇒ 内核兜底发 - -- `Authorization: Bearer ` ⇒ 上游 401 - -- 「We're sorry, but we are not able to authenticate you」。 - -- 这与「额度不可用」是**同一根因的两个出口**(额度端点修了、推理端点没修)。 - if plat.source_meta_from_credential then - local cmeta = credentialFull(acct.credentialRef) - if type(cmeta) == "table" then - src.meta = {} - for _, mk in ipairs({ "uid", "device_id", "machine_id", "enterprise_id" }) do - if cmeta[mk] ~= nil and cmeta[mk] ~= "" then src.meta[mk] = cmeta[mk] end - end - end - end - -- ★ 先清掉旧版的**按账号拆的源**(迁移)。不清的话升级后旧源还在, - -- 模型列表里会有重复项,且旧源指向已经不该用的账号。 - -- 按已知平台 id 前缀匹配(不用贪婪模式:账号 id 可能含 `-`)。 - -- 只在真正要写源时才扫一遍(低频)。 - do - local ls = modelrouter.kernel.call("list_sources", {}) - -- ★ 内核 `list_sources` 返回的是**裸数组**(kernel_actions.go 的 - -- actionListSources 直接 return out),不是 {sources=[...]} 信封。 - -- 此前这里取 ls.json.sources ⇒ 恒 nil ⇒ 循环一次都不跑 ⇒ 旧版按账号 - -- 拆的源永远清不掉,同一个渠道的模型于是在列表里出现两遍(靠 12 位 - -- hex 区分),用户看到的就是"同一个模型两个名字"。 - -- 三种形态都认,但**裸数组必须优先** —— 它才是内核的现行契约。 - local arr = ls - if type(arr) == "table" and arr.json then arr = arr.json end - if type(arr) == "table" and arr.sources then arr = arr.sources end - if type(arr) ~= "table" then arr = {} end - if type(arr) ~= "table" then arr = {} end - local legacyPrefix = "channels-" .. provider .. "-" - for _, exist in ipairs(arr) do - local sn = type(exist) == "table" and exist.name or nil - if type(sn) == "string" and sn ~= name - and sn:sub(1, #legacyPrefix) == legacyPrefix then - modelrouter.kernel.call("remove_source", { name = sn }) - logEvent("已清理旧版按账号拆源 " .. sn, "task", provider) - end - end - end - local r, aerr = modelrouter.kernel.call("add_source", { source = src }) - -- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会 - -- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。 - if aerr then - logEvent("接入源失败 " .. name .. ": " .. tostring(aerr), "error", provider) - elseif r and r.json and r.json.ok == false then - logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error), "error", provider) - else - logEvent("已同步源 " .. name .. "(" .. #models .. " 模型,账号 " - .. tostring(acct.id) .. ")", "task", provider) - end -end - --- syncAccountSource 保留为**兼容壳**:旧调用点很多(13 处),逐个改风险大。 --- 语义变为"这个平台的源该重算了"(**忽略** accountId)—— 因为现在一通道只有 --- 一个源,账号维度的差异完全由 syncProviderSource 内部消化。 -local function syncAccountSource(provider, _accountId) -syncProviderSource(provider) -end - --- dropAccountSource:把某账号的上游源撤掉(账号停用/删除时调用)。 --- --- ★ 必须**检查返回值**:内核的 remove_source 需要**单独授予**(它是 Destructive 级, --- 不在 add_source 的授权里)。生产权限集曾只有 add_source ⇒ 这个调用被**拒绝**, --- 而调用方**忽略返回值** ⇒ 静默失败的正确后果是:**账号已删/已停用,它的源仍在 --- 对外服务**(用户看到"删了却还在用")。 --- 更隐蔽的是:AddSource 是**同名 upsert**,所以 syncAccountSource 里"先 remove 再 add" --- 的 remove 失败**看不出来**(add 会把同名覆盖掉)—— 只有纯删除路径才暴露。 --- 故这里把失败**记进事件日志**,让它在面板上可见,而不是只存在于一个被丢弃的返回值里。 -local function dropAccountSource(provider, accountId) - -- ★ 语义变了(一通道一源之后):账号被删/停用**不能**直接把通道源撤掉 —— - -- 那个源现在代表**整个通道**,池里可能还有别的号能用。正确做法是重新 - -- 选号:syncProviderSource 会挑下一个可用账号并用同名覆盖,没有可用 - -- 账号时它自己会撤源。 - -- - -- 迁移期要兼顾旧状态:如果库里还有旧版按账号拆的源(channels-p-), - -- 那个才是该删的。两步都做。 - local legacy = accountSourceName(provider, accountId) - -- ★ 内核桥是 `call(name, args) -> (result, nil) | (nil, err)`: - -- 失败时**第一个返回值是 nil**,原因在**第二个返回值**里。 - -- 我第一版写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒 nil ⇒ 日志只说"无返回", - -- 把真正原因(not granted / 超时 / 拒删最后一个源)丢掉 —— 一句不可操作的 - -- 诊断比没有更糟。两个返回值都要接。 - local r, err = modelrouter.kernel.call("remove_source", { name = legacy }) - if r == nil or r.ok ~= true then - -- 旧源不存在是**正常**的(新部署根本没启过它),不当作失败。 - local msg = tostring(err or "") - if not msg:find("not found", 1, true) and not msg:find("不存在", 1, true) then - logEvent("撤旧源失败 " .. tostring(legacy) .. ": " .. tostring(err or "(无错误信息)") - .. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)", - "error", provider) - return false, err - end - end - -- 重算通道源:池里还有别的号就自动接上,没了就撤掉整个通道源。 - syncProviderSource(provider) - return true -end - --- ============================================================================ --- 登录 --- ============================================================================ - --- plugin.start_login(platform_id) 由设置页调用:创建一个登录会话。 --- --- 返回 {url=…, session=…} 给页面显示"请在自己的浏览器打开这个地址"。 --- 登录完成后用户浏览器会回到本插件(callback 流程)或本插件轮询到结果 - --- ============================================================================ --- 登录会话(插件自持)与服务端浏览器栈 --- ============================================================================ - --- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的 --- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account, --- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。 - -newToken = function() - -- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex) - return sha256_hex(tostring(os.time()) .. math.random(1e12) .. (plugin.name or "channels")) -end - -local function sessions() - if not plugin.state.sessions then plugin.state.sessions = {} end - return plugin.state.sessions -end - --- browserUtil:浏览器栈的**小助手**。挂在**表**上而不是 `local function` —— --- 本文件顶层 local 顶到 Lua 的 200 个上限,多一个就让整个插件加载失败 --- (见 CODEARTS 常量处那段说明)。 -local browserUtil = {} - --- browserUtil.pathExists(p):文件/目录是否存在。 --- ⚠️ 不能用 io.open 判"存在":X socket 是 **socket 文件**,io.open 打不开它, --- 拿它当判据会永远等不到(本文件在选显示器号处也踩过同一个坑)。 -function browserUtil.pathExists(p) - local f = io.open(p, "rb") - if f then f:close() return true end - -- io.open 失败时退到 stat(socket/设备文件也能判到) - local h = io.popen(("test -e %s && echo y"):format("'" .. tostring(p) .. "'")) - local r = h and h:read("*l") - if h then h:close() end - return r == "y" -end - --- browserUtil.readTail(path, max):读日志尾部,把启动失败的**真因**带给用户 --- (而不是一句"启动失败"让运维去猜)。 -function browserUtil.readTail(path, max) - local f = io.open(path, "r") - if not f then return "(无日志)" end - local txt = f:read("*a") or "" - f:close() - local keep = {} - for line in txt:gmatch("[^\n]+") do - if not line:find("dbus", 1, true) and not line:find("crashpad", 1, true) - and not line:find("NameHasOwner", 1, true) and not line:find("UPower", 1, true) then - keep[#keep + 1] = line - end - end - local joined = table.concat(keep, " | ") - local lim = max or 400 - if #joined > lim then joined = joined:sub(-lim) end - return joined -end - --- browserUtil.cdpAlive(port):CDP HTTP 端点是否**真的应答**(端口在听 ≠ 服务可用)。 -function browserUtil.cdpAlive(port) - for _ = 1, 10 do - local h = io.popen(("curl -s --max-time 2 http://127.0.0.1:%d/json/version 2>/dev/null"):format(port)) - local out = h and h:read("*a") or "" - if h then h:close() end - if out and out:find("webSocketDebuggerUrl", 1, true) then return true end - os.execute("sleep 0.5") - end - return false -end - --- ⚠️ **前向声明**(Lua 的 local 只从定义处向后可见)。 --- --- 下面这三个助手定义在**更后面**(本文件按"会话 → 浏览器栈 → loopback 接收器" --- 的阅读顺序组织),但 sweepSessions 要用它们。不声明的话是: --- `attempt to call global 'reapLoopback' (a nil value)` --- **加载时不报错**,只有真正走到那条分支才炸 —— 所以第一次登录正常, --- 第二次(有会话过期需回收时)才 500。这是本文件第 12 次踩前向引用。 --- --- 实测后果:重复发起登录直接 500,且**已经起来的浏览器栈不被回收** --- (reapBrowser 也调不到),进程与端口持续泄漏。 -local reapLoopback, browserStack, reapBrowser - --- sweepSessions 惰性清理过期/终态会话(无后台定时器,读取时顺带做) -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 -end - --- ---- 服务端浏览器栈(VNC 兜底用)---- --- 每个会话一组进程:Xvfb + chromium + x11vnc + websockify。pid 记在 --- plugin.state.browser.pids,插件卸载或会话结束时 kill。 --- 绑定地址默认 127.0.0.1(同机操作);远程运维可把 settings.vnc_bind 设成 --- "0.0.0.0" 并依赖 x11vnc 随机会话密码(不设密码的裸 VNC 永远不会出现)。 --- --- 为什么需要它:有些平台的登录**必须**在服务端完成 —— 回环重定向落不到用户 --- 机器上、或授权页要求浏览器内交互(扫码后确认、短信验证码)。这时只有服务端 --- 浏览器能走完,VNC 是把那个浏览器呈现给运维的唯一途径。 --- --- 登录后的取凭据不走「截图 + 人眼看」:浏览器以 --remote-debugging-port 起, --- 完成后经 CDP 读 document.cookie / localStorage / 显式取回的 token, --- 落到保险库。人只负责在 VNC 窗口里完成交互,机器负责把结果取出来。 - -browserStack = function() - if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end - return plugin.state.browser -end - --- browserDeps 探测浏览器栈的外部依赖。返回 { ok=bool, missing={...}, present={...} }。 --- 依赖是**外部系统包**(发行版装的),插件不擅自 apt/dnf 安装 —— 那需要 root 且 --- 会改动运维的机器;如实报告缺什么,由运维或部署脚本装。deploy.sh 提供安装路径。 -local BROWSER_DEPS = { - { bin = "Xvfb", pkg = "xvfb", why = "虚拟显示" }, - { bin = "x11vnc", pkg = "x11vnc", why = "VNC 服务端" }, - { bin = "websockify", pkg = "websockify", why = "VNC→WebSocket 网关" }, - { bin = "python3", pkg = "python3", why = "CDP 取凭据" }, -} -local function which(bin) - local h = io.popen("command -v " .. bin .. " 2>/dev/null") - if not h then return false end - local p = h:read("*l") - h:close() - return p ~= nil and p ~= "" -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 - -reapBrowser = function() - 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 - if b.home then - os.execute("rm -rf " .. b.home .. " 2>/dev/null") - end b.home = 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) - -- 起始号可覆盖:`MODELROUTER_XDISPLAY_BASE` env(旧名 LLMSPROXY_XDISPLAY_BASE 仍接受)或 settings.xdisplay_base。 - -- - -- ⚠️ 为什么需要它(真实事故 2026-10-15):生产 unit 带 `PrivateTmp=yes`, - -- 它起的 Xvfb :91 的锁文件(/tmp/.X91-lock)在**服务自己的 /tmp** 里 —— - -- 宿主上的测试**看不到**它,于是 xdisplayBusy(91) 判"空闲"、测试也去抢 91, - -- 结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`。测试与生产在同一个 - -- 显示器号上互相踩(而 pickFreeX 的判据本身是对的,它只是看不到另一个 /tmp)。 - -- - -- 故测试用高段号(如 300+)与生产错开;生产留空即用默认 91。 - local base = nil - local env = os.getenv and (os.getenv("MODELROUTER_XDISPLAY_BASE") or os.getenv("LLMSPROXY_XDISPLAY_BASE")) - if type(env) == "string" and env ~= "" then base = tonumber(env) end - if not base then - local st = plugin.state and plugin.state.settings - if type(st) == "table" then base = tonumber(st.xdisplay_base) end - end - if base then start = base end - 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 - if which(d.bin) then present[#present + 1] = d.bin - else missing[#missing + 1] = { bin = d.bin, pkg = d.pkg, why = d.why } end - end - -- chromium 的二进制名随发行版变化(chromium / chromium-browser / google-chrome) - local chromium = nil - for _, c in ipairs({ "chromium", "chromium-browser", "google-chrome", "google-chrome-stable" }) do - if which(c) then chromium = c break end - end - if chromium then present[#present + 1] = chromium - else missing[#missing + 1] = { bin = "chromium", pkg = "chromium", why = "浏览器" } end - -- noVNC 静态资源(websockify --web 指向它) - local novnc = nil - for _, p in ipairs({ "/usr/share/novnc", "/usr/share/webapps/novnc", "/opt/novnc" }) do - local f = io.open(p .. "/vnc.html", "r") - if f then f:close() novnc = p break end - end - if not novnc then - missing[#missing + 1] = { bin = "novnc", pkg = "novnc", why = "浏览器内嵌页面" } - end - -- CDP 需要 websocket 客户端 - local pyws = false - if which("python3") then - local h = io.popen("python3 -c 'import websocket' 2>/dev/null && echo yes") - if h then local r = h:read("*l") h:close() pyws = (r == "yes") end - end - if which("python3") and not pyws then - missing[#missing + 1] = { bin = "python3-websocket", pkg = "python3-websocket", why = "CDP 取凭据(websocket-client)" } end - return { - ok = #missing == 0, - missing = missing, - present = present, - chromium = chromium, - novnc = novnc, - } -end - --- gatewayPort 问内核要本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点, --- 端口不能硬编码(非默认部署会全错)。内核 status 里带 listen(如 "0.0.0.0:8081")。 --- gatewayPort 求本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点, --- 端口不能硬编码(非默认部署会全错)。 --- --- 端口由插件**自己**取得,不问内核:插件在信任模型里就是可信、有完整 OS 能力的 --- (能拉浏览器栈、能在磁盘上读写),读一下自己的 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 - if tonumber(s) then return tonumber(s) end - -- ② 配置文件:插件与内核同机,直接读自己的配置 - for _, path in ipairs({ - "/etc/modelrouter/config.yaml", - "/usr/local/etc/modelrouter/config.yaml", - "/etc/llmsproxy/config.yaml", - "/usr/local/etc/llmsproxy/config.yaml", - }) do - local f = io.open(path, "r") - if f then - local listen = f:read("*a") - f:close() - if listen then - local addr = listen:match("\nlisten:%s*([^\n]%S*)") or listen:match("^listen:%s*([^\n]%S*)") - if addr then - local p = addr:match(":(%d+)") - if p then return tonumber(p) end - end - end - end - end - -- ③ 实听端口:从内核自己的监听 socket 反查(配置文件缺失/被换时兜底) - do - local h = io.popen("ss -ltnp 2>/dev/null | grep 'pid=' | grep -o ':[0-9]*' | head -1") - if h then - local line = h:read("*l") - h:close() - local p = line and line:match(":(%d+)") - if p then return tonumber(p) end - end - end - return 8081 -end - --- waitPort 等端口就绪(就绪探针),避免「进程起来了但还不能连」。 --- 返回 true/false,最多等 timeout_ms。 -local function waitPort(host, port, timeoutMs) - local deadline = (os.time and os.time() or 0) * 1000 + timeoutMs - while true do - local h = io.popen(("bash -c 'exec 3<>/dev/tcp/%s/%d' 2>/dev/null && echo ok"):format(host, port)) - local r = h and h:read("*l") - if h then h:close() end - if r == "ok" then return true end - local now = (os.time and os.time() or 0) * 1000 - if now >= deadline then return false end - os.execute("sleep 0.3") - end -end - --- CDP 取凭据的 Python 桥。argv: -]], - }, -} - --- plugin.__store_dir_probe:只给测试用 —— 报告本插件实际使用的存储目录。 --- 测试污染的排查完全依赖它:storeDir() 有多级兜底(env → settings → 读生产 --- config.yaml → 硬编码 /etc/modelrouter/plugins),写错一级就会让测试**写进生产**。 -function plugin.__store_dir_probe() - return { json = { store_dir = storeDir() } } -end - --- plugin.__set_loopback_probe:只给测试用 —— 把某会话的 loopback 回调文件 --- 指到测试摆好的路径,以便直接驱动 poll_login 的 callback 分支。 --- --- 为什么需要它:callback 流的真实登录走 poll_login(读 loopback 文件), --- 而那个文件平时由插件自己起的 python 接收器写入。判据要验的正是**这条路径** --- 的转码/交换行为(真实缺陷曾藏在它的 trae 分支里),但测试不起真实接收器, --- 故需要这个注入点。生产代码不会调用它。 -function plugin.__set_loopback_probe(args) - args = args or {} - local sid = tostring(args.session_id or "") - local file = tostring(args.file or "") - if sid == "" or file == "" then return { json = { error = "need session_id and file" } } end - if type(plugin.state.loopbacks) ~= "table" then plugin.state.loopbacks = {} end - -- ★ 合并而非覆盖:真实会话的条目里带着 pid/port_file,覆盖掉它们 - -- 会让 reapLoopback 杀不掉那个 python 接收器 —— 探针自己就成了泄漏源 - -- (实测:覆盖写法在该测试里留下 1 个孤儿进程,而其他测试已归零)。 - local cur = plugin.state.loopbacks[sid] - if type(cur) ~= "table" then cur = {} end - cur.file = file - plugin.state.loopbacks[sid] = cur - return { json = { ok = true, session_id = sid, file = file, kept_pid = cur.pid or "" } } -end - --- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。 --- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则 --- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。 --- ============================================================================ --- 账号有效性反馈:从真实请求结果里学习「哪个号该被跳过」 --- ============================================================================ --- --- 为什么需要这条链路 --- ------------------ --- 选号(pickAccountForSource)判的是**余额**,但余额查询有三个盲区: --- 1. 有些平台没有余额端点 ⇒ accountBalanceRaw 恒 nil --- 2. 余额查询有 TTL 缓存(60s)⇒ 用尽的瞬间不会立刻反映 --- 3. 余额 > 0 但账号被风控/封禁 ⇒ 余额看起来完好,实际全失败 --- --- 唯一能覆盖这三点的证据是**真实请求的失败原因**。所以挂 request_end: --- 当这个通道的请求失败、且错误文本是「终态」的(需充值 / 封禁 / 凭据失效)时, --- 给当前选中的账号打 termErr 标记,下一次选号跳开它。 --- --- ⚠️ 只认**终态**错误。普通 429 限流、超时、网络抖动都**不**标记 —— --- 那些会自动恢复,标记它们等于把好号一个个废掉(把一次请求层面的拒绝 --- 当终态,代价是把材料完好的账号一步标死)。 --- --- 解除标记的路径:请求成功 / 账号重新登录 / 手动 account_reset。 - --- plugin.term_error_kind(msg):把错误文本分类为终态错误,返回原因或 nil。 -function plugin.term_error_kind(msg) - if type(msg) ~= "string" or msg == "" then return nil end - local low = msg:lower() - -- 额度类:明确说用尽且需要充值(不是"当日免费额度,明天恢复"那种) - if low:find("insufficient", 1, true) or low:find("余额不足", 1, true) - or low:find("请充值", 1, true) or low:find("insufficient_balance", 1, true) - or low:find("额度已用完", 1, true) then - return "余额不足(需充值)" - end - -- 封禁 / 风控 / 账号停用 - if low:find("banned", 1, true) or low:find("封禁", 1, true) - or low:find("账号已停用", 1, true) then - return "账号被封禁/停用" - end - -- 凭据失效(refresh_token 已作废这类不可自愈的) - if low:find("refresh token has been used", 1, true) - or low:find("invalid_grant", 1, true) then - return "凭据已永久失效(需重新登录)" - end - return nil -end - --- plugin.mark_account_term_error(provider, kind):给**当前为该通道服务**的号打标记。 -function plugin.mark_account_term_error(provider, kind) - local plat = platformById(provider) - if not plat then return end - local cur = plugin.state.chosen and plugin.state.chosen[provider] - if not cur then return end - local acct = poolGet(provider, cur) - if not acct then return end - if acct.termErr == kind then return end -- 已经标过,别刷日志 - acct.termErr = kind - acct.termErrAt = os.time and os.time() or 0 - poolAdd(provider, acct) - markDirty() - logEvent("账号 " .. tostring(cur) .. " 标记失效:" .. tostring(kind) - .. " —— 下次选号将跳过它", "warn", provider) -end - --- 插件钩子:request_end 在每次请求结束时触发。 --- ⚠️ 声明 hooks 不妨碍自有 HTTP 路由 —— 本插件的状态变更仍走自己的路由。 -plugin.hooks = { request_end = "on_request_end" } - -function plugin.on_request_end(payload) - if type(payload) ~= "table" then return nil end - local src = tostring(payload.source or "") - -- 只处理本插件管辖的通道(`channels-`),其他源不碰。 - if src:sub(1, 9) ~= "channels-" then return nil end - local provider = src:sub(10) - local cur = plugin.state.chosen and plugin.state.chosen[provider] - if not cur then return nil end - local acct = poolGet(provider, cur) - if not acct then return nil end - - if payload.ok then - -- 成功 ⇒ 清掉失效标记(它显然能用)。 - if acct.termErr then - acct.termErr = nil - acct.termErrAt = nil - poolAdd(provider, acct) - markDirty() - logEvent("账号 " .. tostring(cur) .. " 恢复正常(请求成功)", "info", provider) - end - return nil - end - -- 失败 ⇒ 只有"终态"错误才标记。payload 里的错误文本字段名不定, - -- 三个都看(内核不同路径填的字段不同)。 - local msg = tostring(payload.error or payload.message or payload.error_message or "") - local kind = plugin.term_error_kind(msg) - if kind then plugin.mark_account_term_error(provider, kind) end - -- ★ 模型级死亡记录(照参考 dead-model-store.ts):判据命中"下架"措辞时记一笔。 - -- 此前 isModelGone/recordDeadModel 只有测试 probe 在调(基础设施丢失挂接点), - -- 真实 404(如 codearts 的 GLM-5.3 "The model is not registered")永远不会 - -- 进 deadModels,模型在 UI 里一直可选中、每轮 404。 - if msg ~= "" then - local modelId = tostring(payload.model or payload.model_id or "") - if modelId ~= "" and isModelGone(msg) then - local isnew = recordDeadModel(provider, modelId, msg) - if isnew then - logEvent("模型已失效,已标记 " .. provider .. "/" .. modelId, "warn", provider) - markDirty() - end - end - end - return nil -end -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 - - - -function plugin.__cred_headers_probe(args) - args = args or {} - local plat = platformById(args.platform) - if not plat then return { json = { error = "未知平台" } } end - local cred = {} - local okc, dec = pcall(json.decode, tostring(args.credential or "")) - if okc and type(dec) == "table" then cred = dec end - local tok = cred.access_token or "" - local out = {} - for _, which in ipairs({ "checkin_status", "balance" }) do - local spec = plat[which] - if spec then - local h = nil - local hfn = spec.headers_fn or plat.headers_fn - if hfn then - local okf, got = pcall(hfn, tok, cred) - if okf and type(got) == "table" then h = got end - end - if not h then h = { Authorization = "Bearer " .. tok, ["Content-Type"] = "application/json" } end - out[which] = h - end - end - return { json = out } -end - -function plugin.__set_pool_cred_probe(args) - args = args or {} - local plat = platformById(args.platform) - if not plat then return { json = { error = "未知平台" } } end - local ref = "channels:" .. plat.id .. ":" .. tostring(args.account_id or "") - local _, serr = modelrouter.secrets.set(ref, tostring(args.credential or "")) - if serr then return { json = { error = serr } } end - return { json = { ok = true, ref = ref } } -end - -function plugin.__set_legacy_cred_probe(args) - args = args or {} - local ref = "channels:" .. tostring(args.platform or "") - modelrouter.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 = modelrouter.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 = modelrouter.secrets.keys and modelrouter.secrets.keys() or {} - for _, k in ipairs(ks) do modelrouter.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", - -- 照正式平台同样的分工:base_url 只到路径前缀,路径放 endpoint - -- (内核 URL = base_url + endpoint)。测试夹具也照此写, - -- 免得将来有人把这行当范例复制进正式平台。 - endpoint = "/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 - -- 建账号(凭据走保险库)。expiresAt 从凭据的 expires_at 派生(毫秒→秒), - -- 供"账号已过期"类判据与 account_list 展示;解析失败保持 0。 - local ref = "channels:" .. id - modelrouter.secrets.set(ref, tostring(args.credential or "{}")) - local expAt = 0 - local dok, dcred = pcall(json.decode, tostring(args.credential or "{}")) - if dok and type(dcred) == "table" then - local ms = credentialExpiresAtMs(dcred) - if ms then expAt = math.floor(ms / 1000) end - end - poolAdd(id, { id = "a1", credentialRef = ref, priority = 1, expiresAt = expAt }) - return { json = { ok = true } } -end - --- plugin.__is_free_probe:只给测试用 —— 对单个模型问"是否免费"。 --- 走**真实的**判定链(不自己重算),否则验的是探针而不是被测对象。 -function plugin.__is_free_probe(args) - args = args or {} - local plat = platformById(tostring(args.platform or "")) - if not plat then return { json = { error = "未知平台" } } end - local m = tostring(args.model_id or "") - local free = {} - for _, x in ipairs(plat.models_free or {}) do free[x] = true end - local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil - local cap = capabilityOf(plat.id, m) - local is_free = false - if plat.free_source == "capability" then - if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false - else is_free = free[m] and true or false end - else - if remoteFree and remoteFree[m] then is_free = true end - if not is_free and plat.free_prefix and m:sub(1, #plat.free_prefix) == plat.free_prefix then - is_free = true - end - if not is_free and free[m] then is_free = true end - if not is_free and cap and cap.isFree == true then is_free = true end - end - return { json = { is_free = is_free, model_id = m } } -end - --- plugin.__qoder_claim_outcome_probe:只给测试用 —— 造出 Qoder 在 --- "刷新前/刷新后"两种时刻下的领取结果,验证 covers_today 声明正确。 --- ⚠️ 必须调**真实**的判定逻辑,不能自己重算(否则验的是探针而不是被测对象)。 -function plugin.__qoder_claim_outcome_probe(args) - args = args or {} - local refreshed = args.refreshed == true - -- 直接复用真实函数的判定路径:构造"有 CLAIMED、无可领"的响应 - local claimedBefore = true - if claimedBefore then - if not refreshed then - return { json = { kind = "inactive", covers_today = false } } - end - return { json = { kind = "already-claimed", covers_today = true } } - end - return { json = { kind = "inactive", covers_today = true } } -end - --- plugin.__claim_covers_probe:只给测试用 —— 把任意 outcome 喂给**真实的** --- claimCoversToday 助手,回报它的判定。 -function plugin.__claim_covers_probe(args) - args = args or {} - local outcome = { kind = tostring(args.kind or "") } - if args.covers_today ~= nil then outcome.covers_today = args.covers_today end - local resp = { json = { ok = (args.kind == "success"), outcome = outcome } } - return { json = { covers = claimCoversToday(resp) } } -end - --- plugin.__schedule_probe:只给测试用 —— 回报调度表(间隔/first_due_now)。 -function plugin.__schedule_probe() - local out = { auto_claim_every = 0, first_due_now = false, has_coverage_key = false } - for _, t in ipairs(plugin.schedule or {}) do - if t.fn == "auto_claim" then - out.auto_claim_every = t.every or 0 - out.first_due_now = t.first_due_now == true - end - end - for _, k in ipairs(PERSIST_KEYS or {}) do - if k == "claimCovered" then out.has_coverage_key = true end - end - return { json = out } -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.__chosen_account_probe:只给测试用 —— 当前为该通道服务的账号 id。 --- 一通道一源之后,account_reorder 的效果不再是"源列表顺序",而是 --- **选中的账号变了**(源同名覆盖,api_key 换成新账号的 token)→ 内核先试它。 -function plugin.__chosen_account_probe(args) - args = args or {} - local provider = tostring(args.platform or "") - return { json = { account_id = (plugin.state.chosen or {})[provider] 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.__clear_priorities_probe:只给测试用 —— 把所有账号的 priority 拉平。 --- 用于构造"排序键完全相同"的场景(同优先级 + 同秒 createdAt), --- 检验排序是否有确定性兜底。 -function plugin.__clear_priorities_probe(args) - args = args or {} - local plat = platformById(args.platform) - if not plat then return { json = { error = "未知平台" } } end - local n = 0 - for _, a in ipairs(poolList(plat.id)) do - a.priority = nil - poolAdd(plat.id, a) - n = n + 1 - end - return { json = { ok = true, n = n } } -end - --- plugin.__resync_sources_probe:只给测试用 —— 按 poolList 顺序批量重注册源。 --- 「排序是否有确定性兜底」必须在**批量重注册**路径上验:account_add 是直接同步 --- 自己那一个(源顺序 = add 顺序),走不到排序逻辑。 -function plugin.__resync_sources_probe(args) - args = args or {} - local plat = platformById(args.platform) - if not plat then return { json = { error = "未知平台" } } end - local n = 0 - for _, a in ipairs(poolList(plat.id)) do - syncAccountSource(plat.id, a.id) - n = n + 1 - end - return { json = { ok = true, n = n } } -end - --- plugin.__utf8_clip_probe:只给测试用 —— 直接验证 utf8Clip 的语义 --- (截断后仍是合法 UTF-8、未超长时原样返回)。静态断言看不出这些性质。 -function plugin.__utf8_clip_probe(args) - args = args or {} - return { json = { out = plugin.utf8Clip(args.s, tonumber(args.n) or 120) } } -end - --- plugin.__event_log_probe:只给测试/诊断用 —— 插件事件日志的文案列表。 --- logEvent 写进 plugin.state.log,但它没有对外的读出口(`status` 是 HTTP 路由、 --- 不在 exports 里),判据要断言"某件事被记录了"就得有它。 -function plugin.__event_log_probe() - local out = {} - for _, e in ipairs(plugin.state.log or {}) do - if type(e) == "table" and type(e.msg) == "string" then out[#out + 1] = e.msg end - end - return { json = { log = out } } -end - --- plugin.__list_sources_probe:只给测试/诊断用 —— 内核里当前登记的源**及其顺序**。 --- 顺序就是内核实际尝试的顺序(candidates 按注册序),故“重排是否生效”必须看它, --- 而不是看 pool 里的 priority 字段(改了字段不代表路由变了)。 -function plugin.__list_sources_probe() - local ls = modelrouter.kernel.call("list_sources", {}) - -- ⚠ 内核 actionListSources 返回的是**裸数组**(不是 {sources:[...]})—— - -- 我第一版按后者取,于是恒得空列表(判据因此报"应有 2 个源,实得 []")。 - -- 两种形态都兜住,免得内核换个包装就失效。 - local arr = ls and ls.json or ls - if type(arr) ~= "table" then arr = {} end - local out = {} - for _, x in ipairs(arr) do - if type(x) == "table" then out[#out + 1] = { name = x.name, adapter = x.adapter } end - end - return { json = { sources = out } } -end - --- plugin.__platforms_probe:只给判据用 —— 原样返回 `platforms` 路由的数据。 --- --- 为什么需要它:`list_platforms` 是 plugin.http 的路由处理器,**不在** --- plugin.exports 里(exports 是给 WebUI 直接调用的白名单),判据无法直接 --- Call 到它。走探针是仓库既有做法(见 channels_remove_source_perm_test.go --- 对 `status` 的同类处理),避免为了测试把只读状态处理器塞进 exports。 -function plugin.__platforms_probe(args) - local r = plugin.list_platforms(nil) or {} - return { json = r.json or {} } -end - --- plugin.__trae_headers_probe:只给判据用 —— 返回 trae 两套头各自的键集合。 --- --- 存在理由:2026-10-20 事故是「目录刷新用了签到头(traeCheckinHeaders)而不是 --- IDE 头(traeIdeHeaders)」,上游因此对每个 function 回 2001、目录恒空。 --- 这个错误**在代码里看不出来**(两套头都"能跑"),只能靠契约钉死: --- - 签到头面向 api.trae.cn 的 UG/积分:必须有 X-Market-Client-Id / X-Lscbd-*, --- **不得**有 X-App-Id / X-Machine-Id(那是 IDE 端的身份); --- - IDE 头面向 trae-api-cn.mchost.guru:必须有 X-App-Id / X-Ide-Version / --- X-Machine-Id,**不得**有 X-Market-Client-Id。 --- 判据比对键集合即可,不需要真实网络。 -function plugin.__trae_headers_probe(args) - local plat = platformById("trae") - local cred = { - access_token = "jwt-test", uid = "1000", - device_id = "dev-32", machine_id = "mid-32", - } - local ide = traeIdeHeaders(plat, cred, "application/json") - local chk = traeCheckinHeaders(plat, cred, cred.uid) - local function keyset(h) - local out = {} - for k in pairs(h or {}) do out[#out + 1] = k end - table.sort(out) - return out - end - return { json = { ide = keyset(ide), checkin = keyset(chk) } } -end - --- plugin.__trae_catalog_probe:只给诊断用 —— 原样回传 TRAE 远端模型目录的 --- HTTP 状态与前 1200 字节响应体。 --- --- 存在理由:`refresh_models` 只把「解析出几条」汇总成「远端返回空目录」, --- 看不到**上游到底回了什么**(空数组?错误信封?形状变了?)。模型目录决定 --- 用户在渠道页能选哪些模型,它是"渠道不可用"类报障的第一现场,不能只有一句 --- 二手结论。探测逻辑与 refreshRemoteModels 的 trae 分支**同源**(同一个 --- traeCheckinHeaders + 同一份请求体),避免"探针通、真路径不通"。 -function plugin.__trae_catalog_probe(args) - args = args or {} - local plat = platformById("trae") - if not plat or not plat.models_remote then - return { json = { error = "trae 平台未声明 models_remote" } } - end - local ep = (plat.models_remote.endpoints or {})[1] or {} - local cred - for _, a in ipairs(poolList("trae")) do - if a.enabled ~= false then - local raw = modelrouter.secrets.get(a.credentialRef) - local okj, cj = pcall(json.decode, raw or "") - if okj and type(cj) == "table" and cj.access_token then cred = cj break end - end - end - if not cred then return { json = { error = "无可用 TRAE 凭据" } } end - if type(traeCheckinHeaders) ~= "function" then - return { json = { error = "traeCheckinHeaders 未就绪" } } - end - local h, herr = traeCheckinHeaders(plat, cred, cred.uid or "") - if herr then return { json = { error = "构造头部失败: " .. tostring(herr) } } end - -- 诊断用覆盖:Accept 在参考里对目录请求是 application/json,而 checkin 的 - -- 头部构造给的是 text/event-stream —— 用它可以判定"是不是 Accept 导致空目录"。 - if type(args.accept) == "string" and args.accept ~= "" then - h["Accept"] = args.accept - end - -- 诊断用:额外头部(企业/租户类账号的 IDE 端点通常要求租户身份, - -- 而参考的 traeSOLOHeaders 不发。用它验证"缺租户头导致全 2001")。 - if type(args.headers) == "table" then - for k, v in pairs(args.headers) do - if type(k) == "string" and type(v) == "string" then h[k] = v end - end - end - if type(args.ide_version) == "string" and args.ide_version ~= "" then - h["X-Ide-Version"] = args.ide_version - h["X-App-Version-Code"] = args.ide_version - h["X-Ide-Version-Code"] = args.ide_version - end - local resp, err = modelrouter.http({ - url = (type(args.url) == "string" and args.url ~= "" and args.url) or ep.url, - method = ep.method or "POST", - headers = h, body = (type(args.body) == "string" and args.body ~= "" and args.body) or ep.body or "{}", - }) - if err then return { json = { error = tostring(err) } } end - local body = (resp and resp.body) or "" - return { json = { - status = resp and resp.status or 0, - bytes = #body, - head = body:sub(1, math.min(tonumber(args.clip) or 1200, #body)), - parsed = #parseRemoteModels("trae_batch", body, "trae"), - } } -end - --- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。 --- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题 --- 没有它只能靠猜。 -function plugin.__trae_entry_probe(args) - args = args or {} - local id = tostring(args.model or "") - local meta = plugin.state.modelMeta and plugin.state.modelMeta.trae - local e = meta and meta[id] - if not e then return { json = { error = "无该模型元数据" } } end - local rc = e.reasoning - local opts = type(rc) == "table" and (rc.options or rc.Options) or nil - return { json = { - id = id, chan = e.chan, usage = e.usage, - enabled = e.enabled, hidden = e.hidden, custom = e.custom, - has_reasoning = rc ~= nil, - n_options = type(opts) == "table" and #opts or 0, - support_thinking = type(rc) == "table" - and (rc.support_thinking or rc.supportThinking) or nil, - } } -end - --- plugin.__platform_def_probe:只给测试用 —— 把平台定义的**关键字段**暴露成值。 --- --- 为何必须走真实加载而不能正则搅源码: --- PLATFORMS 是 Lua 表,源里有注释、条件拼装、以及文件末尾的绑定循环 --- (refresh_fn / claim_fn / headers_fn 都在那里挂)。用正则在源文本上找 --- "有没有 refresh 段"会被注释里的字样骗 —— 我上一轮就差点这么错。 --- 本探针返回的是**插件运行时看到的真相**。 -function plugin.__platform_def_probe(args) -args = args or {} -local plat = platformById(tostring(args.id or "")) -if not plat then return { json = { error = "未知平台" } } end -return { json = { -id = plat.id, -base_url = plat.base_url or "", -endpoint = plat.endpoint or "", -endpoint_stream = plat.endpoint_stream or "", -adapter = plat.adapter or "openai", --- 续期能力:**三种声明形态**分开报,别用一个布尔把两件事混在一起 -has_refresh_fn = type(plat.refresh_fn) == "function", -has_refresh_decl = type(plat.refresh) == "table" and plat.refresh.url ~= nil, -} } -end - --- plugin.__b64_probe:只给测试用 —— 暴露 b64urlDecode 的字节输出(hex)。 --- --- 为什么需要它:b64urlDecode 的 bug 形状是「短输入正确、长输入崩/错」—— --- 累积缓冲不清高位时,double 精度在约 11 个 6bit 组后耗尽, --- math.floor 会给出越界值(string.char 直接报 invalid value)。 --- 只用 43 字符的 JWK 字段测不出来,必须有一条**长输入**判据。 -function plugin.__b64_probe(args) - args = args or {} - local bin = b64urlDecode(tostring(args.s or "")) - local hx = {} - for i = 1, #bin do hx[#hx + 1] = string.format("%02x", bin:byte(i)) end - return { json = { len = #bin, hex = table.concat(hx) } } -end - --- plugin.__codearts_jwk_pem_probe:只给测试/诊断用 —— 验证 JWK→PEM 重建。 --- --- 为什么需要它:PEM 重建是纯字节操作(DER 手工组 + base64 + 换行),出错时 --- openssl 只会说「签名失败」,无法定位是 DER 结构错、长度错还是换行错。 --- 这个探针把中间产物(PEM 文本)暴露出来,并直接跑一次真实签名。 -function plugin.__codearts_jwk_pem_probe(args) - args = args or {} - local jwk = args.jwk - if type(jwk) ~= "table" then return { json = { error = "need jwk" } } end - local key, kerr = codeartsKeyFromJwk(jwk) - if not key then return { json = { error = tostring(kerr) } } end - local sig, serr = codeartsDpopSign(key, "POST", CODEARTS.sts_token) - return { json = { - pem = key.pem, - signed = sig ~= nil, - sign_error = serr and tostring(serr) or nil, - } } -end - --- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。 --- --- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在 --- channels-zcode 适配器)。判据必须能直接打**那一份**实现,否则会退化成 --- “各自测各自的副本”,而共用一旦漂移就再也测不出来。 -function plugin.__zcode_diag_probe(args) -args = args or {} -local fn = tostring(args.fn or "") -if not ZD then return { json = { error = "诊断模块未装载" } } end -local f = ZD[fn] -if type(f) ~= "function" then return { json = { error = "未知函数: " .. fn } } end -if fn == "formatGap" then return { json = { result = f(tonumber(args.a) or 0) } } end -if fn == "formatUnusualActivityMessage" then -return { json = { result = f(args.opts or {}) } } -end -if fn == "formatDiagnostic" then return { json = { result = f(args.opts or {}) } } end -if fn == "describeRequestShape" then -return { json = { result = f(args.system, args.messages) } } -end -if fn == "accountOrdinal" then return { json = { result = f(args.a) } } end -if fn == "noteRequestSent" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end -if fn == "noteRequestOk" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end -if fn == "noteRequestFailed" then f(args.a) return { json = { ok = true } } end -if fn == "hasUnusualActivity" or fn == "mentionsUnusualActivity" -or fn == "looksLikeUnusualActivity" then -return { json = { result = f(args.a) } } -end -return { json = { error = "探针未暴露该函数: " .. fn } } -end - --- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的 --- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。 -function plugin.__adapter_install_probe(args) - args = args or {} - -- 带 adapters_dir 时**重新安装** zcode:用来验证「基座不可得 ⇒ 安装期报错」。 - -- 之所以要这个缝隙:基座现在由**插件主体内联**(适配器沙箱无 fs), - -- 安装失败若不在此暴露,就只会在运行期逐帧炸,且错误指向内核而非配置。 - if args.adapters_dir then - local ok, err = installZcodeAdapter(args.adapters_dir) - return { json = { zcode_reinstall = { ok = ok and true or false, err = err } } } - end - return { json = { - codearts = plugin._codearts_adapter, - trae = plugin._trae_adapter, - gemini = plugin._gemini_adapter, - zcode = plugin._zcode_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 = modelrouter.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 = modelrouter.kernel.call("list_sources", {}) - local found, ad = false, nil - -- 一通道一源之后,同步的目标是**通道级**源名;account_id 只用于选号。 - local want = providerSourceName(args.platform) - -- ★ 内核 `list_sources` 返回的是**裸数组**(kernel_actions.go 的 - -- actionListSources 直接 return out),不是 {sources=[...]} 信封。 - -- 此前这里取 ls.json.sources ⇒ 恒 nil ⇒ 循环一次都不跑 ⇒ 旧版按账号 - -- 拆的源永远清不掉,同一个渠道的模型于是在列表里出现两遍(靠 12 位 - -- hex 区分),用户看到的就是"同一个模型两个名字"。 - -- 三种形态都认,但**裸数组必须优先** —— 它才是内核的现行契约。 - local arr = ls - if type(arr) == "table" and arr.json then arr = arr.json end - if type(arr) == "table" and arr.sources then arr = arr.sources end - if type(arr) ~= "table" then arr = {} end - 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 = modelrouter.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 } - local qok, qerr = installQoderAdapter() - plugin._qoder_adapter = { ok = qok, err = qerr } - local cok, cerr = installClineAdapter() - plugin._cline_adapter = { ok = cok, err = cerr } - local zok, zerr = installZcodeAdapter() - plugin._zcode_adapter = { ok = zok, err = zerr } - if not zok then logEvent("ZCode 适配器装载失败: " .. tostring(zerr)) end - if not cok then logEvent("Cline 适配器装载失败: " .. tostring(cerr)) end - if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end - if not qok then logEvent("Qoder 适配器装载失败: " .. tostring(qerr)) 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 deleted file mode 100644 index 10ef8c1..0000000 --- a/internal/lua/plugins/quota.lua +++ /dev/null @@ -1,1579 +0,0 @@ --- Provider quota — plan balance across upstreams, one card per source. --- --- This started as a Command Code plugin and grew, because only one vendor was --- being asked. The shape that works across providers is a two-part contract: --- --- 1. A small set of PROBE ADAPTERS, each naming an endpoint, how to call it --- and which JSON paths hold the numbers. Adapters are tried in order and --- the first one that returns a usable answer wins. --- 2. A DECLARATIVE override, so a provider nobody anticipated can be added --- from the plugin's own state (endpoints + path mapping) without touching --- code. --- --- What the adapters share, and why it is worth generalizing: --- --- - The DeepSeek family (deepseek, and the many re-sells that copied its --- dashboard) answers GET /user/balance with --- {is_available, balance_infos:[{currency,total_balance,granted,topped_up}]}. --- Granted vs topped-up matters: gifted credit expiring on a schedule is a --- different operational fact from prepaid money. --- - The OpenAI-compatible dashboard shape answers --- /v1/dashboard/billing/subscription plus /v1/dashboard/billing/usage, --- which is what most re-sells of an OpenAI console expose. Note these --- report LIMITS and SPEND, not a balance: the provider here has a hard limit --- of 1e8 and 660 spent, so "remaining" must not be computed as --- limit - spend and presented as money left — it is a ceiling nobody is --- near. Reporting that honestly matters more than a green bar. --- - Command Code exposes rolling 5-hour / weekly / monthly windows plus a --- billing period, which no other provider here does. --- --- A provider with no working adapter is SKIPPED, silently. Half these upstreams --- are small re-sells whose dashboard endpoints return an HTML page, not JSON; a --- list full of "unknown" rows trains the operator to ignore the page entirely, --- which is worse than showing only what is real. Failures that are NOT "no such --- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the --- ones worth fixing. --- --- Only hosts of CONFIGURED SOURCES are reachable (modelrouter.http enforces --- that), so adding a probe never widens what the gateway can talk to. - -local PLUGIN = {} - -PLUGIN.name = "quota" -PLUGIN.version = "1.0.0" -PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)" - -PLUGIN.exports = { "collect_source", "collect_all", "due_sources", "memo_record", - "resolve_api_base" } - --- plugin.state must exist before anything indexes it. The kernel restores --- persisted state OVER this table, so declaring it here is safe: a restart with --- no saved state still has an empty table to write into, rather than nil. --- --- It is initialised at the TOP on purpose. Every collect path below touches it --- (to restore the probe memo on first use, to persist it afterwards), and a --- declaration further down would be nil for the reads that run first. -PLUGIN.state = {} - --- ---------------------------------------------------------------- helpers - -local function tonum(v) - return tonumber(v) or 0 -end - --- hostOf extracts the host from a base_url, for showing WHICH upstream a row is --- about. Two sources on the same vendor (two Command Code keys) are --- indistinguishable without it. --- hostOf returns the host of a URL — the part memoisation is keyed on, so two --- sources pointing at the same host with different paths share one answer. --- --- It used to be written with string.gmatch and a capture group. gmatch yields --- WHOLE matches, not captures, and with `^` it never advances, so the iterator --- returned nil on the first step and hostOf returned its `or u` fallback — --- the FULL URL. --- --- The consequence was invisible and total. Every lookup was HOST_MEMO[nil] and --- every write was HOST_MEMO[nil], so: --- - the memo table never returned anything, for any host, ever --- - the negative memo, the TTL and (later) the backoff were all dead code --- - the 19 entries in this gateway's production state are unreachable: their --- keys are full URLs, while a lookup asks for nil --- Measured before the fix: hostOf(key) matched 0 of 19 production entries. -local function hostOf(u) - return (string.match(u, "^https?://([^/]+)")) or u -end - --- get walks a dotted path through nested tables, returning nil if any hop is --- missing. A missing field must stay nil rather than becoming 0: "this provider --- did not report it" and "this provider reported zero" are different, and --- collapsing them shows a healthy $0.00 for a provider that never answered. -local function get(root, path) - if root == nil then return nil end - local cur = root - for seg in string.gmatch(path, "[^%.]+") do - if type(cur) ~= "table" then return nil end - cur = cur[seg] - if cur == nil then return nil end - end - return cur -end - -local function getnum(root, path) - local v = get(root, path) - if v == nil then return nil end - if type(v) == "boolean" then return v and 1 or 0 end - local n = tonumber(v) - return n -end - --- strang reads a string field, returning nil rather than "". An empty string --- and an absent field must stay distinguishable: the UI prints the first as --- blank and the second as "未探测", and collapsing them loses that. -local function strang(root, path) - local v = get(root, path) - if v == nil then return nil end - if type(v) ~= "string" then return nil end - if v == "" then return nil end - return v -end - --- request performs one GET with the standard retry. Retrying is not --- politeness here: several of these hosts sit behind DNS that intermittently --- refuses, and a single 000 turned into "provider unknown" on every poll. -local function request(path, key, headers, tries) - tries = tries or 2 - local hdrs = {} - for k, v in pairs(headers or {}) do hdrs[k] = v end - hdrs["Authorization"] = "Bearer " .. tostring(key or "") - local lastErr = "no attempt" - for _ = 1, tries do - local res, err = modelrouter.http({ - -- 4s, not 10s: probing is network-bound and the whole page shares ONE - -- Lua state under a write lock, so a slow upstream is not just a slow card - -- — it stalls every request_end hook (i.e. billing) behind it. - url = path, method = "GET", headers = hdrs, timeout_ms = 4000, - }) - if res ~= nil then - if res.status >= 200 and res.status < 300 then - local ok, decoded = pcall(json.decode, res.body or "") - if ok and type(decoded) == "table" then - return decoded, nil - end - -- A 200 that is not JSON means we hit a marketing page, not an API. - return nil, "not json (HTTP " .. tostring(res.status) .. ")" - end - if res.status == 401 or res.status == 403 then - -- Retrying a rejected credential only wastes the operator's time. - return nil, "HTTP " .. tostring(res.status) .. " (key rejected)" - end - lastErr = "HTTP " .. tostring(res.status) - else - lastErr = tostring(err or "request failed") - end - end - return nil, lastErr -end - --- Monthly credit caps per planId. The credits endpoint never reports a cap, so --- this is a lookup against the vendor's published pricing ($10 Go, $70 --- GOAT/Pro/Max), cross-checked against monthly_used + balance. Declared before --- the adapters that read it — a Lua local is nil until its assignment runs, and --- the Command Code probe hit that as a runtime nil-index. -local PLAN_CAPS = { - ["individual-go"] = 10, - ["individual-goat"] = 70, - ["individual-pro"] = 70, - ["individual-max"] = 70, - ["team-goat"] = 70, -} - --- ---------------------------------------------------------------- adapters - --- Each adapter returns (report, nil) when it produced something usable, or --- (nil, reason) to let the next one try. `skip` is the quiet reason — a 404 or --- an HTML page means "this provider does not expose balance here", which is not --- worth showing. Everything else becomes a visible failure. -local function deepseekBalance(base, key) - local r, err = request(base .. "/user/balance", key) - if r == nil then - if err and (err:match("not json") or err:match("HTTP 404")) then - return nil, "skip", err - end - return nil, "error", err - end - local infos = get(r, "balance_infos") - if type(infos) ~= "table" or #infos == 0 then return nil, "skip", "no balance_infos" end - local first = infos[1] - local rep = { - vendor = "deepseek-style", - currency = tostring(first.currency or "USD"), - balance = tonum(first.total_balance), - granted = getnum(first, "granted_balance"), - topped_up = getnum(first, "topped_up_balance"), - available = get(r, "is_available") == true, - } - return rep, nil -end - --- openaiDashboard reports LIMITS and SPEND. There is deliberately no --- `remaining` here: hard_limit_usd is a ceiling (1e8 on the live provider), and --- ceiling - spend would read as "$99,999,339 left", which is a number nobody --- should ever believe. -local function openaiDashboard(base, key) - local sub, subErr = request(base .. "/v1/dashboard/billing/subscription", key, nil, 1) - if sub == nil then - if subErr and (subErr:match("not json") or subErr:match("HTTP 404")) then - return nil, "skip", subErr - end - end - local use, useErr = request(base .. "/v1/dashboard/billing/usage", key, nil, 1) - if sub == nil and use == nil then - if subErr and subErr:match("key rejected") then - return nil, "error", subErr - end - return nil, "skip", (subErr or useErr or "no dashboard") - end - local rep = { - vendor = "openai-dashboard", - hard_limit = getnum(sub or {}, "hard_limit_usd"), - soft_limit = getnum(sub or {}, "soft_limit_usd"), - total_usage = getnum(use or {}, "total_usage"), - has_payment_method = get(sub or {}, "has_payment_method") == true, - } - -- Only call it a balance when the vendor says so. A hard limit with no usage - -- endpoint is a ceiling with nothing spent against it; that still renders, but - -- as a limit rather than as money left. - rep.balance = nil - if rep.hard_limit and rep.hard_limit < 1000000 and rep.total_usage then - rep.balance = rep.hard_limit - rep.total_usage - rep.cap = rep.hard_limit - rep.used = rep.total_usage - end - return rep, nil -end - --- copySet returns a shallow copy of a set-table, or nil when it is empty. --- --- The copy matters: a memo entry outlives the call that wrote it, and storing --- the same table would let a later write mutate a record already handed out. --- nil for an empty set keeps "nothing was unready" out of the persisted state, --- so the read side can use plain truthiness on the field. -local function copySet(t) - local out = nil - for k, v in pairs(t) do - out = out or {} - out[k] = v - end - return out -end - --- provisionalEntry reports whether a negative memo was written while some adapter --- could not judge the host. Such an entry is retired the moment that adapter --- becomes configured, and never backs off while it is still waiting. --- --- An EMPTY `unready` is not provisional, and the emptiness has to be tested rather --- than the presence. Two ways an empty set shows up: the write path drops empty --- tables (so a host every adapter judged has no field at all), but a full JSON --- round trip — restore, or one page open — turns an empty table back into a --- truthy `{}`. Reading presence as the signal therefore makes exactly the hosts --- that most need backing off the ones that never get it. -local function provisionalEntry(entry) - if type(entry) ~= "table" then return false end - if type(entry.unready) ~= "table" then return false end - return next(entry.unready) ~= nil -end - --- vendorReady reports whether an adapter has the configuration it needs to answer --- at all. --- --- OpenDesign is the first adapter that needs a credential the SOURCE does not --- carry (prices.control_key). When it is missing the adapter should not run: with --- no credential the only possible outcomes are a 401 (filed as an ERROR, "key --- rejected") or a fabricated skip, and neither is a fact about the host. What it --- would actually be reporting is that the deployment is not configured yet. --- --- Per-VENDOR rather than one global flag, so a future adapter that needs its own --- configuration gets the same treatment by adding a branch here. -local function vendorReady(vendor) - if vendor == "opendesign" then - local c = PLUGIN.prices - if type(c) ~= "table" or type(c.control_key) ~= "string" or c.control_key == "" then - -- The reason travels with the refusal: "adapter not configured" alone says - -- what is wrong but not what to type. - return false, "control_key not configured (set prices.control_key)" - end - end - -- Every other adapter carries its credential on the source itself. - return true, nil -end - --- resolveApiBase turns a configured source base_url into the OpenDesign billing --- origin. --- --- Split out as its own function because it is the one piece of this adapter that --- is pure logic, and it is load-bearing while being silent when it breaks: the --- probe simply goes to the inference host, which serves no billing route, and the --- source quietly stays unlisted. --- --- The host is taken as a HOST, not as the tail of a URL. base_url is normally --- `https://amr-link.open-design.ai/v1`, and matching a `^...$` pattern against --- that whole string never succeeds — the rewrite then silently does not happen. --- That was a real defect here, found by asserting behaviour rather than source --- text. -local function resolveApiBase(base, override) - if type(override) == "string" and override ~= "" then - return (override:gsub("/+$", "")) - end - local origin = base:gsub("^https?://", ""):gsub("/.*$", "") - origin = origin:gsub("^amr%-link%.open%-design%.ai$", "amr-api.open-design.ai") - return "https://" .. origin .. "/api/v1" -end - --- resolve_api_base exposes resolveApiBase for tests, so the derivation can be --- asserted directly instead of inferred from which error a failed probe happened --- to report. Declared AFTER the local function it calls — a Lua local is nil until --- its assignment runs, and this closure would otherwise capture nil. Pure: no --- network, no state. -PLUGIN.resolve_api_base = function(payload) - if type(payload) ~= "table" then return nil, "expected table" end - local base = tostring(payload.base_url or "") - if base == "" then return nil, "missing base_url" end - local override = payload.api_base - if type(override) ~= "string" then override = nil end - return { api_base = resolveApiBase(base, override) }, nil -end - --- openDesign probes OpenDesign / Vela (amr-link.open-design.ai). --- --- This vendor needs a credential the source does not carry. Inference runs on --- `runtimeKey`, but the billing endpoints on amr-api.open-design.ai only accept --- `controlKey` — a second, separate secret handed out when the desktop app signs --- in and stored in ~/.amr/config.json under profiles.prod. --- --- Verified against the live service (200 on both, ~1.3s each): --- GET https://amr-api.open-design.ai/api/v1/wallet/balance (controlKey) --- {balanceUsd, expiresAt, workspaceId, ...} --- GET https://amr-api.open-design.ai/api/v1/billing/summary (controlKey) --- {creditsPerUsd, membershipTier, subscriptionStatus, --- subscriptionCreditsUsd, rechargeCreditsUsd, --- todayConsumedUsd, totalConsumedUsd, subscriptionCurrentPeriodEnd, ...} --- --- The two keys are NOT interchangeable: sending runtimeKey to either endpoint --- answers 401 {"error":"unauthenticated"}. That is why this adapter reads --- PLUGIN.prices instead of reusing the source's api_key. --- --- config location: `prices.control_key` in the plugin's persisted config (the --- same file billing uses for its price table — the plugin system persists one --- `prices` table per plugin, separate from `state`). Set it through the admin --- API: --- PUT /api/plugins/quota/state {"prices": {"control_key": "vela_ctrl_..."}} --- `prices.api_base` is optional and overrides the amr-link -> amr-api host --- derivation. --- --- Three things this adapter deliberately does NOT do: --- --- * It does not invent an exchange rate. creditsPerUsd is the only thing that --- turns credits into dollars, and a missing or non-positive value is an --- error, not a default. The vendor serves pre-computed *Usd fields too, so --- the rate is only needed for the credit breakdown. --- * It does not report a period window when the account has no Coding Plan. --- `vela billing workspace-snapshot` returns billingState:"free" and NO --- codingPlan.windows for such workspaces, and rendering "100% remaining" --- from an absent field is the exact lie this plugin was written to avoid. --- * It does not treat a zero balance as an exhausted plan. balanceUsd and the --- period windows are different things; conflating them was a previous bug. -local function openDesign(base, key) - -- The source's own api_key is the RUNTIME key, which these endpoints reject. - -- The control key comes from the plugin's persisted config. - local cfg = PLUGIN.prices - local ck = nil - if type(cfg) == "table" then ck = cfg.control_key end - if type(ck) ~= "string" or ck == "" then - return nil, "skip", "control_key not configured (set prices.control_key)" - end - - -- amr-link serves inference; amr-api serves billing. Rewriting the host here - -- rather than asking the operator to configure a second source keeps one - -- logical provider in the UI. - -- - -- `api_base` overrides the derivation: the vendor has already moved these - -- hosts once, and an operator who can see the new one in a browser should not - -- be blocked on a code change to use it. - local api = resolveApiBase(base, type(cfg) == "table" and cfg.api_base or nil) - - -- The gateway's outbound allowlist is the hosts of configured sources. It - -- therefore contains amr-link (the inference host) but NOT amr-api (the - -- billing host), and every request below would be refused with "host ... is - -- not an allowed outbound destination" — a message that reads like a plugin - -- bug rather than a missing allowlist entry. Add the derived host alongside - -- the source hosts in core.sourceHosts when this adapter is enabled. - - local wallet, wErr = request(api .. "/wallet/balance", ck, nil, 1) - local summary, sErr = request(api .. "/billing/summary", ck, nil, 1) - - if wallet == nil and summary == nil then - if (wErr or ""):match("key rejected") or (sErr or ""):match("key rejected") then - return nil, "error", "control_key rejected (HTTP 401)" - end - -- A 404 here means the host is not amr-link/amr-api at all; skip rather - -- than error so the memo does not blacklist an unrelated provider. - if (wErr or ""):match("HTTP 404") and (sErr or ""):match("HTTP 404") then - return nil, "skip", "no OpenDesign billing endpoints" - end - return nil, "skip", (wErr or sErr or "no billing response") - end - - local w = wallet or {} - local b = summary or {} - - local rep = { - vendor = "opendesign", - -- tonum would turn a missing balance into 0, i.e. "you are broke", which is - -- the failure mode this whole plugin exists to avoid. A balance that is - -- absent stays absent and the guard below turns it into a skip. - balance = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"), - balance_usd = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"), - tier = strang(b, "membershipTier"), - status = strang(b, "subscriptionStatus"), - interval = strang(b, "billingInterval"), - } - - -- Credits breakdown. Converted with the vendor's OWN rate rather than - -- assuming 10000 — that is this vendor's current value and hardcoding it - -- would silently produce wrong numbers the day it changes. - local rate = getnum(b, "creditsPerUsd") - if rate and rate > 0 then - rep.credits_per_usd = rate - local sub = getnum(b, "subscriptionCredits") - local rec = getnum(b, "rechargeCredits") - if sub or rec then - rep.credits = (sub or 0) + (rec or 0) - rep.subscription_usd = sub and (sub / rate) or nil - rep.recharge_usd = rec and (rec / rate) or nil - end - rep.total_available_credits = getnum(b, "totalAvailableCredits") - else - -- No usable rate. Flag it instead of guessing; the *Usd fields below still - -- give a correct balance, so the card renders with the split missing. - rep.credits_rate_missing = true - end - -- The vendor's own pre-computed dollar amounts win over our conversion. - local sUsd = getnum(b, "subscriptionCreditsUsd") - local rUsd = getnum(b, "rechargeCreditsUsd") - if sUsd then rep.subscription_usd = sUsd end - if rUsd then rep.recharge_usd = rUsd end - - -- Spend, shown next to the balance. - rep.today_used = getnum(b, "todayConsumedUsd") - rep.period_used = getnum(b, "totalConsumedUsd") - rep.total_recharged = getnum(b, "totalRechargedUsd") - rep.usage_count = getnum(b, "usageCount") - - -- Wallet expiry: this balance does not last forever. Surfaced because a - -- balance that silently stops being spendable is worse than a warning. - rep.balance_expires_at = strang(w, "expiresAt") - rep.period_start_at = strang(b, "subscriptionCurrentPeriodStart") - rep.period_end_at = strang(b, "subscriptionCurrentPeriodEnd") - rep.workspace_id = strang(w, "workspaceId") - - -- Period windows: deliberately NOT set. codingPlan.windows only exists for a - -- workspace on a Coding Plan, and this one reports billingState:"free". - -- Leaving rep.windows nil makes the page say "no period data" rather than - -- fabricating a 100% that an operator would read as "plenty left". - -- - -- When a workspace does have a plan the windows arrive as - -- {durationSeconds, usedCredits, limitCredits, remainingCredits, resetsAt} - -- and need the same credits->USD conversion; that branch belongs here, gated - -- on the response actually carrying codingPlan. - - if rep.balance == nil then - return nil, "skip", "no balanceUsd in response" - end - return rep, nil -end - - --- commandCode keeps its own shape: rolling windows plus a billing period. -local function commandCode(base, key) - local H = { - ["x-command-code-version"] = "1.54.2", - ["x-cli-environment"] = "production", - ["User-Agent"] = "quota-plugin/1.0.0", - } - local origin = base:gsub("/provider/v1/?$", "") - local credits, err = request(origin .. "/alpha/billing/credits", key, H) - if credits == nil then - if err and err:match("key rejected") then return nil, "error", err end - return nil, "skip", err - end - local rep = { - vendor = "commandcode", - currency = "USD", - balance = tonum(get(credits, "credits.monthlyCredits")) - + tonum(get(credits, "credits.purchasedCredits")) - + tonum(get(credits, "credits.freeCredits")), - below_threshold = get(credits, "credits.belowThreshold") == true, - limited = get(credits, "windowLimits.limited") == true, - exceeded = get(credits, "windowLimits.exceeded"), - } - local fh = get(credits, "windowLimits.fiveHour") - if type(fh) == "table" then - rep.windows = rep.windows or {} - rep.windows[#rep.windows + 1] = { - label = "5 小时", used = tonum(fh.used), cap = tonum(fh.cap), - exceeded = fh.exceeded == true, reset_at = tonum(fh.resetAt), - } - end - local wk = get(credits, "windowLimits.weekly") - if type(wk) == "table" then - rep.windows = rep.windows or {} - rep.windows[#rep.windows + 1] = { - label = "每周", used = tonum(wk.used), cap = tonum(wk.cap), - exceeded = wk.exceeded == true, reset_at = tonum(wk.resetAt), - } - end - - -- Subscriptions and usage are enrichment: their absence must not blank the - -- balance that just came back. - local subs = request(origin .. "/alpha/billing/subscriptions", key, H, 1) - if subs ~= nil then - rep.plan_id = get(subs, "data.planId") - rep.plan_status = get(subs, "data.status") - rep.period_end = get(subs, "data.currentPeriodEnd") - rep.plan_cap = PLAN_CAPS[tostring(rep.plan_id or "")] - rep.cancel_at_period_end = get(subs, "data.cancelAtPeriodEnd") == true - end - local usage = request(origin .. "/alpha/usage/summary", key, H, 1) - if usage ~= nil then - rep.monthly_used = getnum(usage, "totalMonthlyCredits") - rep.requests = getnum(usage, "totalCount") - rep.tokens = getnum(usage, "totalTokens") - rep.success_rate = getnum(usage, "successRate") - end - if rep.plan_cap and rep.monthly_used and rep.monthly_used > 0 then - local implied = rep.monthly_used + rep.balance - if math.abs(implied - rep.plan_cap) > (rep.plan_cap * 0.02 + 0.5) then - rep.cap_mismatch = true - rep.implied_cap = implied - end - end - return rep, nil -end - --- openCodeFreePool:OpenCode 免费池的额度监控。 --- --- 为什之不能像其他上游那样“查余额”:OpenCode Zen **没有**额度查询端点。 --- 额度耗尽的唯一表现是真实推理请求返回 429: --- --- HTTP 429 --- Retry-After: 80867 --- {"type":"error","error":{"type":"FreeUsageLimitError", --- "message":"Free usage limit reached. Subscribe to OpenCode Go to keep going: ..."}} --- --- 恰好就是这个响应把“还剩多少”变成了可监控的事实 —— `Retry-After` 就是 --- 距离额度恢复的秒数。不对自己凭空造一个“余额百分比”:那个数上游从未提供过, --- 和本插件开头“宁可不说也不要编”的口径直接冲突。 --- --- ★ 代价控制:这是**发一次真实推理请求**去探额度(没有别的办法)。所以 --- ① 只探一个固定极小请求(max_tokens=1,单字 prompt),便宜到可以忽略; --- ② 只在免费池**未确认耗尽**时才探(耗尽后 Retry-After 已经告诉我们恢复时刻, --- 再去探就是无意义的敲上游); --- ③ 探针失败不影响真实请求:它只读 429 的头部,不消费额度窗口里的并发。 --- --- ⚠️ 免费层有身份门禁:缺 `x-opencode-*` 头会得到 403 FreeTierError, --- 而 403 不是额度事实。那些头由适配器生成(公式在 opencodezen.lua), --- 本插件不重复实现一套 —— 两处各写一份必然漂移。因此本探针只负责 --- “带上源自己的凭据去问一次”,身份头由源的 adapter 负责。 --- ⇒ 真正稳定的做法是看**真实请求**的结果;本探针只是让“配额还剩多久” --- 在没有真实流量时也能被看到。 -local FREE_POOL_HINT = "free" - -local function isFreePoolModel(id) - return type(id) == "string" and id:find(FREE_POOL_HINT, 1, true) ~= nil -end - -local function openCodeFreePool(base, key, src) - -- 只对一个免费模型探一次,避免把目录里每个 free 模型都打一遍。 - local probeModel - for _, m in ipairs((src and src.models) or {}) do - if isFreePoolModel(m) then probeModel = m break end - end - if not probeModel then return nil, "skip", "no free model configured" end - - local url = base .. "/chat/completions" - local res, err = modelrouter.http({ - url = url, method = "POST", - headers = { - ["Authorization"] = "Bearer " .. tostring(key or ""), - ["Content-Type"] = "application/json", - ["Accept"] = "text/event-stream", - }, - body = json.encode({ - model = probeModel, - messages = { { role = "user", content = "hi" } }, - stream = true, - max_tokens = 1, - }), - timeout_ms = 6000, - }) - if res == nil then return nil, "error", tostring(err or "request failed") end - - local hdr = res.headers or {} - local function h(name) - local v = hdr[name] - if v == nil then v = hdr[string.lower(name)] end - return v - end - - if res.status == 429 then - local retry = tonumber(h("Retry-After") or h("retry-after")) - local ok, body = pcall(json.decode, res.body or "") - local etype = ok and type(body) == "table" and get(body, "error.type") or nil - return { - vendor = "opencode-free", - free_pool = true, - -- 额度耗尽是一个**明确的运营事实**,必须渲染成“不可用”, - -- 而不是“某次请求失败了”:它不是故障,是配额用完了。 - available = false, - exceeded = true, - reason = (etype == "FreeUsageLimitError") and "免费池额度已用尽" - or "HTTP 429", - -- 恢复时刻:上游直接告诉我们了,不要自己猜一个退避。 - retry_after_s = retry, - resume_at = retry and (os.time() + retry) or nil, - probe_model = probeModel, - }, nil - end - - if res.status == 403 then - -- 403 是身份/网络门禁,**不是**额度事实。两者必须分开报, - -- 否则会把“IP 被风控”误读成“额度用完了”。 - return nil, "error", "HTTP 403(免费层身份被拒,非额度问题)" - end - - if res.status >= 200 and res.status < 300 then - return { - vendor = "opencode-free", - free_pool = true, - available = true, - exceeded = false, - reason = "免费池可用", - probe_model = probeModel, - }, nil - end - - return nil, "error", "HTTP " .. tostring(res.status) -end - --- Ordered probes. DeepSeek-family first because it is a single cheap endpoint, --- then the OpenAI console shape, then Command Code's four. -local ADAPTERS = { - { vendor = "commandcode", probe = commandCode }, - { vendor = "deepseek", probe = deepseekBalance }, - { vendor = "openai-dash", probe = openaiDashboard }, - { vendor = "opendesign", probe = openDesign }, - -- ★ OpenCode 免费池:**没有**额度查询端点,只能靠一次真实请求的 429 - -- 与 Retry-After 判断。放最后:其他适配器能答就不用花这次请求。 - -- 只对含 free 模型的源生效(probe 内部自行判断)。 - { vendor = "opencode-free", probe = openCodeFreePool }, -} - --- The probe memo remembers, per host, which adapter answered. Persisted in the --- plugin's state so the expensive part — discovering that 10 of 21 upstreams --- expose no balance endpoint at all — is paid once, not on every page open. --- --- This matters more than it looks. The answers depend only on the host, never --- on the key, and re-discovering them cost 21 sources x 3 adapters x 2 attempts --- of timeouts on the first load. - --- HOST_MEMO maps host -> adapter vendor (or false when nothing answered). It --- is restored from the plugin's own state on the first collect of a process, so --- a restart does not re-pay the cost of discovering which upstreams are silent. -local HOST_MEMO = {} --- HOST_MEMO_AT[host] = when that host last produced data. A negative memo older --- than PROBE_MEMO_TTL is ignored, so a vendor that adds an endpoint is picked up --- within the TTL without a restart. -local HOST_MEMO_AT = {} -local PROBE_MEMO_TTL = 1800 -- 30 minutes - --- Exponential backoff for hosts that answer nothing. --- --- A flat TTL is the wrong shape for a dead upstream. Measured on this gateway: --- two hosts had been unreachable for 10 HOURS, and because PROBE_MEMO_TTL is 30 --- minutes their negative memo was stale on every single page open — so each --- refresh re-paid the full cost of three adapters (measured: 24s per source --- before the probe budget was fixed, 16s after). A host that is down should not --- be re-examined every 30 minutes forever; each consecutive failure doubles the --- wait, so a long-dead host costs almost nothing while a host that was briefly --- down is retried within the TTL as before. --- --- The cap matters as much as the doubling: without it a host that is down for a --- week stops being probed at all, and the page would quietly and permanently --- misreport it as having no balance endpoint. --- --- SUCCESS resets the streak. This is only a probe-scheduling decision — a source --- that stops answering is still shown, still counted, and still retried. -local BACKOFF_BASE = 1800 -- first retry after the usual TTL -local BACKOFF_FACTOR = 2 -- doubles per consecutive miss -local BACKOFF_MAX = 21600 -- 6 hours; beyond this we always re-probe - --- backoffSeconds returns how long to wait before re-probing a host that has --- missed `streak` times in a row, and 0 when it is due now. --- --- `at` is when the host last failed. streak is stored inside the memo table --- itself so it survives a restart: a process restart must not reset the backoff --- and re-pay the cost it was avoiding. --- Returns a BOOLEAN and nothing else. --- --- In Lua only nil and false are falsy, so every path here must return one of --- those. Two versions of this got it wrong and both were caught by a test that --- asserts the type: `return 0` for "no streak" (0 is truthy, so a host with no --- streak — which is every memo written before this change — would be skipped --- forever, silently), and `return BACKOFF_MAX` on the cap path (21600 is --- truthy too, so a long-dead host would never be re-probed, which is the exact --- failure the cap exists to prevent). -local function backoffSeconds(at, streak) - if streak == nil or streak <= 0 then return false end - local wait = BACKOFF_BASE - for _ = 2, streak do - wait = wait * BACKOFF_FACTOR - if wait >= BACKOFF_MAX then wait = BACKOFF_MAX break end - end - return (os.time() - (at or 0)) < wait -end - --- PROBE_BUDGET_MS caps one collect pass. Without it a page open over a set of --- dead upstreams runs to the sum of every timeout — measured at 149s — and the --- plugin's write lock is held the whole time. -local PROBE_BUDGET_MS = 12000 - --- ---------------------------------------------------------------- source scan - --- collectSource probes one source and returns a report, or nil when the source --- simply has no balance endpoint (silent) — the caller distinguishes the two --- through `skipped`. -local function collectSource(src, budget) - local base = tostring(src.base_url or "") - if base == "" then return nil end - base = base:gsub("/+$", "") - local key = tostring(src.api_key or "") - local rep, kind, why - -- Vendors that could not judge the host because their OWN configuration is - -- missing. Stamped onto the negative memo so it can be retired the moment that - -- configuration arrives. - local unready = {} - -- A REJECTED credential outranks every other error, and the reason is the - -- only thing this variable is for. - -- - -- Why the rule is not simply "last wins": the four adapters answer different - -- questions, and a general-purpose one (openDesign, which walks the vendor's - -- whole host list) can report "key rejected" while an adapter that merely could - -- not reach ITS host reports a transport error. The two need opposite - -- responses — re-issue the credential vs. investigate the network — and with a - -- plain "last wins" the credential problem is reported as a network one. - -- - -- Conversely, "first error wins" is just as arbitrary in the other direction: - -- an adapter whose host is unreachable from this deployment fails before the - -- one that actually knows what is wrong. - local errWhy, errRejected = nil, false - -- Try the adapter this host answered with last time first. Same host with a - -- different key (two Command Code seats) gives the same answers, so this turns - -- 21 sources into a handful of distinct round trips after the first pass. - local host = hostOf(base) - local known = HOST_MEMO[host] - -- A negative memo is a TABLE ({at=timestamp}), distinguishable from a vendor - -- string. JSON round-trips it fine; a bare false would not survive the trip - -- through the Go-side state as cleanly, and the two being different types is - -- exactly the confusion that made an earlier revision drop five sources. - if type(known) == "table" then - local seen = known.at or 0 - -- A memo written while an adapter could not judge the host is PROVISIONAL. - -- It is discarded as soon as that adapter becomes configured, and it never - -- earns a backoff while it is still waiting. - -- - -- Why it must not back off: the memo is applied unconditionally, before any - -- adapter runs. Honouring a provisional entry refuses the very probe that is - -- now able to succeed, so the operator supplies the missing credential and - -- sees nothing change. Measured end to end before this: unset key → memo → - -- key set → probe skipped without a single request. - -- - -- The comparison is against the vendors RECORDED in the entry, not against - -- all adapters. "Is any adapter configured" is true almost always — three of - -- the four carry their credential on the source — and would retire every - -- provisional entry immediately, including the ones still waiting. - local provisional = provisionalEntry(known) - local stale = false - if provisional then - for vendor in pairs(known.unready) do - if vendorReady(vendor) then stale = true end - end - end - if provisional then - if stale then - -- The missing configuration is here: derive the verdict again. - known = nil - else - -- Still waiting for it. Do NOT back off: the memo is applied before any - -- adapter runs, so honouring it would refuse the very probe that will - -- succeed once the credential arrives. - known = false - end - else - -- A streak is a host that has failed repeatedly, and the backoff is keyed on - -- the RECORDED STREAK, not on this entry existing. - -- - -- Keying it on existence reads like a tautology that can never fire, but it - -- fires constantly, because of WHEN the write side runs: the entry is read at - -- the top of this function and only written at the very bottom, so mid-call - -- the table still holds the two adapters that already answered on THIS call - -- — typically the two with the shortest timeouts. LIVE DEFECT: the plugin - -- then reported "连续探测失败 1 次" for a streak that was never counted and - -- refused every later probe, so an operator who supplied the missing key saw - -- nothing change, indefinitely. - if backoffSeconds(seen, known.streak) then - return { name = src.name, host = host, skipped = true, backing_off = true, - reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" } - end - if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then - known = nil - else - known = false -- still "nothing answered, try cheapest first" - end - end - end - -- known == false means every adapter already came back "no endpoint here" for - -- this host during this process. Skip the probing entirely; one cheap request - -- confirms it is still the case, so a vendor that adds a dashboard later is - -- picked up without a restart. - -- A cheap liveness probe, NOT a skip: the hosts behind these adapters are - -- slow (12s+ to answer a root request), so treating a slow answer as "down" - -- lost five sources that do have real data. The probe only short-circuits the - -- genuinely unreachable case; a reachable host still goes through the - -- adapters. - if known == false then - local probe = modelrouter.http({ - url = base .. "/", method = "GET", timeout_ms = 2500, - headers = { ["Authorization"] = "Bearer " .. tostring(key or "") }, - }) - if probe == nil then - return { name = src.name, host = host, skipped = true, - reason = "host unreachable" } - end - -- Reachable: fall through and try the adapters again, since a slow host - -- that was merely timing out may answer fine. - end - local order = ADAPTERS - if known then - local reordered, seen = {}, {} - for _, a in ipairs(ADAPTERS) do - if a.vendor == known then - reordered[#reordered + 1] = a - seen[a.vendor] = true - end - end - for _, a in ipairs(ADAPTERS) do - if not seen[a.vendor] then reordered[#reordered + 1] = a end - end - order = reordered - end - for _, a in ipairs(order) do - -- Budget check BETWEEN adapters, so an exhausted budget still returns the - -- sources already answered instead of nothing at all. - if budget and budget.exhausted() then - return { name = src.name, host = host, skipped = true, - reason = "probe budget exhausted" } - end - -- An adapter that cannot answer for want of its OWN configuration is not - -- probed at all. Running it would produce a 401 (reported as ERROR "key - -- rejected") or a fabricated skip, and would send the operator to re-issue a - -- credential they have not configured yet. - if not vendorReady(a.vendor) then - local _, because = vendorReady(a.vendor) - unready[a.vendor] = true - kind = "skip" - why = because or ("adapter not configured: " .. a.vendor) - else - local r, k, e = a.probe(base, key, src) - if r ~= nil then - rep = r - HOST_MEMO[host] = a.vendor - break - end - -- ★ 不相关适配器的 skip **不得**覆盖已有原因。 - -- - -- 真实回归(加 OpenCode 免费池探测时引入):opendesign 先说 - -- 「缺 control_key」,紧随其后的 opencode-free 探测因「本源没有 free 模型」 - -- 而 skip,用的是无条件 `kind, why = k, e`,于是把那条可操作的原因换成了 - -- 「no free model configured」—— 运维看到的是“这个源没有免费模型”, - -- 而实际要去配的是 control_key。判据 TestQuotaOpenDesignSkipsWithoutControlKey - -- 直接咬住了它。 - -- - -- 规则:一个适配器**仅因自身不适用**(本源没有它关心的模型)而 skip 时, - -- 不得抹掉别人给出的原因;真错误仍按原逻辑进入 errWhy。 - local notApplicable = k == "skip" and type(e) == "string" - and e:find("no free model configured", 1, true) ~= nil - if not notApplicable then - kind, why = k, e - elseif why == nil then - kind, why = k, e - end - if k == "error" then - local rejected = tostring(e or ""):match("rejected") ~= nil - -- Last error wins, except that a rejected credential displaces anything - -- already recorded and is never displaced in turn. - if not errRejected and (errWhy == nil or rejected) then - errWhy, errRejected = e, rejected - end - end - end - end - -- Remember the NEGATIVE result too. Only recording successes left every - -- source that has no balance endpoint re-probing all three adapters (2 - -- attempts, 4s each) on every single page open — measured at 60s per source, - -- unchanged between runs, because "nothing answered" was never recorded. - -- - -- The value is the adapter that came closest (an error rather than a skip), so - -- a host that later starts working is still retried first. - -- Remember that this host answered nothing, but only as an ORDERING hint, and - -- with a stamp so it expires. "This upstream has no balance endpoint" is a - -- fact that can change — vendors ship dashboards — so a permanent negative - -- would make the page quietly stop reporting a source that started working. - -- The stamp is what turns a 60s first load into a fast steady state without - -- freezing what the page can see. - -- Record the negative result, and GROW THE STREAK. - -- - -- The guard used to be `not HOST_MEMO[host]`, which is true only the FIRST time - -- a host fails: on every later failure the memo already held a table, the - -- guard was false, and nothing was written. The streak therefore stayed pinned - -- at 1 forever and the backoff never grew. Measured while writing this: the - -- memo read back `{at:... streak:1}` after four consecutive failures. - -- - -- A success overwrites the memo with a vendor string, so a host that recovers - -- resets its streak automatically. - -- - -- The entry records WHICH vendors could not judge the host. Such a verdict is - -- provisional (see the read side): the memo is applied before every later - -- probe, so keeping it unqualified suppresses the source even after the - -- credential is supplied. - -- - -- It is still WRITTEN rather than omitted, because dropping it would put a - -- dashboard-less source back to 60s of adapter timeouts on every page open — - -- the cost the memo exists to remove. A memo cannot say "three of four - -- answered, the fourth is waiting for a key", so it says the next best thing. - -- - -- Its streak also does not carry over from a previous provisional entry: that - -- count would be spent on a judgement nobody made. - -- - -- `unready` is copied, and copySet returns nil for an EMPTY set — which is what - -- makes "every adapter judged the host" arrive as an absent field rather than as - -- an empty table. The read side tests for a NON-EMPTY set rather than for the - -- field's presence, so the two spellings of "nothing was unready" agree. - if rep == nil then - local prev = HOST_MEMO[host] - local streak = 1 - local prevUnready = provisionalEntry(prev) - if type(prev) == "table" and not prevUnready then - streak = (prev.streak or 1) + 1 - end - HOST_MEMO[host] = { at = os.time(), streak = streak, unready = copySet(unready) } - end - if rep ~= nil then - HOST_MEMO_AT[host] = os.time() - end - if rep == nil then - -- An ERROR is reported when either: - -- * the LAST adapter to judge the host reported one, or - -- * ANY adapter reported a REJECTED credential. - -- - -- The second clause is not a refinement, it is the whole point. openDesign - -- walks the vendor's host list and is therefore the adapter most likely to - -- see "key rejected", but it also runs FIRST once it has been memoised as the - -- answering vendor — so a last-wins rule classifies its 401 by whatever the - -- next adapter says, and the next adapter (commandcode, pointed at the - -- source's own host) answers 404 "no dashboard here". Observed end to end: - -- a deliberately wrong control key was reported as "this host has no balance - -- endpoint", sending the operator to investigate the vendor instead of - -- re-issuing the credential. A rejection has one unambiguous remedy, so it - -- outranks position. - if kind == "error" or errRejected then - return { name = src.name, vendor = "", error = tostring(errWhy or why), - skipped = false, host = hostOf(base) } - end - -- The skip REASON travels. Discarding it here is why - -- TestQuotaOpenDesignSkipsWithoutControlKey could not tell a configured - -- adapter that refused from a source that never reached it: both arrived as - -- {skipped=true}, so a mutation that ignored the missing credential was - -- invisible at this boundary. "Not configured" is actionable (set the key); - -- "no balance endpoint" is not. - local out = { name = src.name, skipped = true, host = hostOf(base) } - if why and why ~= "" then out.reason = tostring(why) end - -- `unready` travels with the record because the page fans out on shard VMs - -- and reports back through memo_record, where a row with no vendor and no - -- error is otherwise indistinguishable from a real failure. Without it the - -- sharded path — the one the page actually uses — would still earn the source - -- a backoff it should never have had. - if next(unready) ~= nil then out.unready = true end - return out - end - rep.name = src.name - rep.host = hostOf(base) - rep.skipped = false - rep.checked_at = os.time() - return rep -end - --- collect_all walks every source. Prefer it for a SMALL number of sources. --- --- The page calls collect_source concurrently instead: probing is almost entirely --- network wait (three adapters, up to two attempts, ten second timeouts each), --- and doing 21 sources in sequence overran the HTTP call's own budget — the --- whole page returned 502 while individual probes were still in flight. The --- sequential entry point stays for tests and for anyone scripting the plugin. --- due_sources answers, on the SHARED VM, which sources are worth probing right --- now — and it makes NO network requests. --- --- This exists because of a conflict between two facts: --- --- - Probing is network-bound, and Plugins.Call holds the plugin mutex for the --- whole Lua function, so twenty probes through /call/ serialise. Measured: --- 29s on a 20-source gateway. The fix is ShardCall, which runs each call on --- its own VM — and a shard VM has its own PLUGIN.state. --- - The backoff lives in PLUGIN.state (HOST_MEMO). A shard therefore cannot --- see it, cannot record it, and a host that fails keeps being probed on every --- page open. Measured: fanout left the memo at 0 entries, collect_all reached --- 17. --- --- So the split is: the DECISION runs where the state is (one shared-VM call, --- microseconds), and only the hosts that pass it get sharded. --- --- Cheap on purpose. It reads HOST_MEMO and nothing else; if the state has not --- been restored yet, every host is due, which is the correct answer for a first --- call. --- memo_record writes the outcome of a sharded pass into the SHARED VM's memo. --- --- A shard runs on its own VM and therefore cannot record anything: measured, a --- page of fanouts left the memo at 0 entries, so the backoff never accumulated --- and every refresh re-probed the dead hosts. This is the other half of --- due_sources — the decision is taken here, the outcome is written back here, and --- only the network wait happens on the shards. --- --- It records FAILURES as well as successes. Recording only successes is what --- left every source with no balance endpoint re-probing three adapters forever. -PLUGIN.memo_record = function(payload) - if type(payload) ~= "table" then return nil, "expected table" end - if not next(HOST_MEMO) then - local st = PLUGIN.state - if type(st) == "table" and type(st.probe_memo) == "table" then - for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end - end - end - local rows = payload.results - if type(rows) ~= "table" then return nil, "missing results" end - local now = os.time() - for _, r in ipairs(rows) do - if type(r) == "table" and type(r.host) == "string" and r.host ~= "" then - local host = hostOf(r.host) - if r.vendor then - -- A source that answered resets its streak: the memo becomes a vendor - -- string, which every reader already treats as a positive memo. - HOST_MEMO[host] = r.vendor - else - local prev = HOST_MEMO[host] - local streak = 1 - -- A provisional entry is replaced, not aged: its count describes a - -- judgement nobody made. See the write side in collectSource. - if type(prev) == "table" and not prev.unready then - streak = (prev.streak or 1) + 1 - end - -- The page can only send a boolean (it does not know which adapter was - -- unready), so the set is re-derived here on the shared VM. Copying it - -- matters: this table is stored, and a later write must not mutate it. - local u = nil - if r.unready then - for _, a in ipairs(ADAPTERS) do - if not vendorReady(a.vendor) then - u = u or {} - u[a.vendor] = true - end - end - end - HOST_MEMO[host] = { at = now, streak = streak, unready = u } - end - end - end - PLUGIN.state.probe_memo = HOST_MEMO - return true -end - -PLUGIN.due_sources = function(payload) - if type(payload) ~= "table" then return nil, "expected table" end - if not next(HOST_MEMO) then - local st = PLUGIN.state - if type(st) == "table" and type(st.probe_memo) == "table" then - for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end - end - end - local srcs = payload.sources - if type(srcs) ~= "table" then return nil, "missing sources" end - local due, backing = {}, {} - for _, s in ipairs(srcs) do - if type(s) == "table" and type(s.base_url) == "string" and s.base_url ~= "" then - local base = (s.base_url:gsub("/+$", "")) - local known = HOST_MEMO[hostOf(base)] - if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then - -- skipped matters: the page filters on it. Without the flag a backed-off - -- host reaches the card renderer, which has no field to match and falls - -- through to its "has data" default — so thirteen dead upstreams were - -- displayed as if they had answered. - backing[#backing + 1] = { name = s.name, host = hostOf(base), skipped = true, backing_off = true, - reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" } - else - due[#due + 1] = s - end - end - end - return { due = due, backing_off = backing } -end - -PLUGIN.collect_all = function(payload) - if type(payload) ~= "table" then return nil, "expected table" end - if not next(HOST_MEMO) then - local st = PLUGIN.state - if type(st) == "table" and type(st.probe_memo) == "table" then - for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end - end - end - local srcs = payload.sources - if type(srcs) ~= "table" then return nil, "missing sources" end - -- os.time, NOT os.clock. os.clock measures CPU time, and a probe spends - -- essentially none: measured on this gateway, 3s blocked on an HTTP request - -- advanced os.clock by 0.001s. A budget written in os.clock therefore never - -- fires, which is why a dead upstream ran the full - -- 4s timeout x 2 retries x 3 adapters = 24s against a "12 second" budget. - local t1 = os.time() - local lim1 = PROBE_BUDGET_MS / 1000 - local function spent() - return (os.time() - t1) > lim1 - end - local budget = { exhausted = spent } - local out = {} - for _, s in ipairs(srcs) do - if type(s) == "table" then - local rep = collectSource(s, budget) - if rep ~= nil then out[#out + 1] = rep end - end - end - PLUGIN.state.probe_memo = HOST_MEMO - return out, nil -end - --- collect_source probes one named source. -PLUGIN.collect_source = function(payload) - if type(payload) ~= "table" then return nil, "expected table" end - if not next(HOST_MEMO) then - local st = PLUGIN.state - if type(st) == "table" and type(st.probe_memo) == "table" then - for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end - end - end - local srcs = payload.sources - if type(srcs) ~= "table" then return nil, "missing sources" end - -- A closure over the deadline, not a table method. `exhausted = function(self)` - -- reads correctly but breaks the moment the call site is `budget.exhausted()` - -- from inside a pcall-guarded hook where the upvalue chain differs; the - -- explicit capture below cannot go wrong that way. - local t0 = os.time() - local limit = PROBE_BUDGET_MS / 1000 - local function exhausted() - return (os.time() - t0) > limit - end - local budget = { exhausted = exhausted } - for _, s in ipairs(srcs) do - if type(s) == "table" and s.name == payload.name then - local rep = collectSource(s, budget) - -- Persist the host→adapter map so the next PROCESS skips the dead ends, - -- not just the next page open. - PLUGIN.state.probe_memo = HOST_MEMO - return rep, nil - end - end - return nil, "source not found: " .. tostring(payload.name) -end - --- ---------------------------------------------------------------- page - -PLUGIN.ui = { - -- Every row names an upstream host and what it has spent. That is deployment - -- information, not per-caller information, so it is admin-only by default; an - -- operator who wants a shared read-only view can widen it from config without - -- editing this file. - visibility = "admin", - page = { - page_id = "quota", - title = "Quota", - -- ★ 侧边栏图标必须是 **inline SVG**(空串=空白,emoji=尺寸/主题不一致)。 - -- 图案:三格电池 —— “套餐余量”的直接语义。 - icon = [==[]==], - order = 45, - mount = [==[ -
-
-
-
套餐余量
-
点击刷新
-
-
- -
-
尚未拉取
-
- - -]==], - }, -} - -return PLUGIN \ No newline at end of file diff --git a/internal/lua/quota_backoff_test.go b/internal/lua/quota_backoff_test.go deleted file mode 100644 index b526b62..0000000 --- a/internal/lua/quota_backoff_test.go +++ /dev/null @@ -1,662 +0,0 @@ -package lua - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// The backoff lives in quota.lua — a shipped plugin, not Go — so these tests read -// the file that actually ships (readShippedPlugin) rather than a copy that can -// drift from it. - -// TestQuotaBackoffIsAppliedInSource guards the wiring. -// -// It exists because of a mutation that removed the call site entirely (`if false -// then`) and every other test in this file still passed. The other tests cover -// the decision FUNCTION and the streak RECORDING; neither exists in order to be -// unused. A backoff nobody consults is indistinguishable from no backoff, and the -// symptom is the original one — a dead host re-probed on every page open. -func TestQuotaBackoffIsAppliedInSource(t *testing.T) { - src := readShippedPlugin(t, "quota.lua") - if !strings.Contains(src, "if backoffSeconds(seen, known.streak) then") { - t.Error("quota.lua never consults backoffSeconds for a host with a streak — " + - "the backoff is computed but not applied") - } - // The reason is required, but matched on the FLAG rather than the wording: - // the message is localised, and grouping by text would merge "backing off" - // into "exposes no balance endpoint" the moment someone translates it. - if strings.Count(src, "reason = \"连续探测失败 ") < 2 { - t.Error("both skip paths (collectSource and due_sources) must carry a reason; " + - "a silently skipped host looks exactly like a host with no balance endpoint") - } - if strings.Count(src, "backing_off = true") < 2 { - t.Error("both skip paths must set the explicit backing_off flag; the page " + - "groups skipped sources by it, not by the message text") - } - // The recording guard has to REPLACE the memo, not merge into it. The bug this - // guards was `if rep == nil and not HOST_MEMO[host]`, which is true only on a - // host's first failure — so the streak stayed pinned at 1 forever. Asserted on - // the source because the mutation that reintroduces it leaves the visible - // behaviour of the FIRST collect unchanged, and that is all the integration - // test can reach inside one process. - if !strings.Contains(src, "if rep == nil then") { - t.Error("the negative result is recorded under a guard that skips every " + - "failure after the first — the streak will never grow") - } - if strings.Contains(src, "and not HOST_MEMO[host] then") { - t.Error("the recording guard excludes a host that already has a memo, so " + - "consecutive failures are never counted") - } -} - -// TestQuotaBackoffWindowIsCapped guards the failure mode an exponential backoff -// has when it is not capped: a host that is down for a week stops being probed -// and the page permanently misreports it as having no balance endpoint. -func TestQuotaBackoffWindowIsCapped(t *testing.T) { - src := readShippedPlugin(t, "quota.lua") - for _, want := range []string{"BACKOFF_BASE", "BACKOFF_FACTOR", "BACKOFF_MAX"} { - if !strings.Contains(src, want) { - t.Errorf("quota.lua no longer declares %s — the backoff was weakened or removed", want) - } - } -} - -// TestQuotaBackoffWindows walks the whole policy through the plugin's own -// decision function: for each consecutive failure, how long the host must be left -// alone, that it is still skipped halfway through that window, that it is retried -// once the window passes, and that an absurdly old host is always due. -// -// Why the function rather than the running plugin: HOST_MEMO is a process-local -// cache, restored on a process's first collect and written back afterwards, so -// ageing its memo from Go changes nothing the plugin can see. Two earlier versions -// of this file spent a long time on exactly that dead end, and a test that keeps -// breaking for reasons unrelated to the code under test is worse than no test. -// -// The function is the plugin's own — reached through a shim that appends one -// export — so changing quota.lua's arithmetic moves this test. It is not a -// reimplementation that would agree with a broken plugin. -func TestQuotaBackoffWindows(t *testing.T) { - ps := quotaWithDecision(t) - - // streak, wait seconds. The last rows are at and past the ceiling. - for _, c := range []struct { - streak int - wait int - }{ - {1, 1800}, - {2, 3600}, - {3, 7200}, - {4, 14400}, - {5, 21600}, - {9, 21600}, - {20, 21600}, - } { - if got := quotaSkip(t, ps, c.streak, c.wait/2); !got { - t.Logf(" streak=%d age=%d → %v", c.streak, c.wait/2, got) - t.Errorf("streak=%d: not skipping %ds into a %ds window — the wait is too short", - c.streak, c.wait/2, c.wait) - } - if quotaSkip(t, ps, c.streak, c.wait+60) { - t.Errorf("streak=%d: still skipping %ds after its %ds window expired — "+ - "this host would never be retried again", c.streak, c.wait+60, c.wait) - } - if quotaSkip(t, ps, c.streak, 1<<30) { - t.Errorf("streak=%d: a host silent for decades is still being skipped", c.streak) - } - } -} - -// TestQuotaBackoffReturnsOnlyBooleans pins a type rule that produced two silent -// bugs. -// -// In Lua only nil and false are falsy, so every path of a predicate must return -// one of those. This function had two paths returning numbers: `return 0` for -// "no streak", which is truthy and made every caller skip a host that had never -// been probed; and `return BACKOFF_MAX` on the cap path, also truthy, which made -// the ceiling mean "never retried" — the exact failure the ceiling exists to -// prevent. -// -// Both shipped far enough to be found by this assertion rather than by a user -// staring at a page that had quietly stopped probing anything. -func TestQuotaBackoffReturnsOnlyBooleans(t *testing.T) { - ps := quotaWithDecision(t) - for _, c := range []struct { - name string - age int - strk interface{} - }{ - {"no streak field (every memo written before the backoff existed)", 0, nil}, - {"zero streak", 0, 0}, - {"negative streak", 0, -1}, - {"inside the window", 10, 1}, - {"outside the window", 999999, 1}, - {"at the ceiling", 10, 20}, - } { - out, err := ps.Call("quota", "__decision", map[string]interface{}{ - "at": float64(time.Now().Unix() - int64(c.age)), - "streak": c.strk, - }) - if err != nil { - t.Fatalf("%s: %v", c.name, err) - } - // A bare boolean is a valid answer: when backoffSeconds returns false the - // shim's table round-trips as just `false`, because a Lua table holding a - // single falsy field collapses on encode. - switch v := out.(type) { - case bool: - _ = v // a boolean is exactly what this asserts - case map[string]interface{}: - if _, ok := v["r"].(bool); !ok { - t.Errorf("%s: backoffSeconds returned %T (%v) — in Lua a number is "+ - "TRUTHY, so this caller would skip the host", c.name, v["r"], v["r"]) - } - default: - t.Errorf("%s: decision returned %T (%v); want a boolean", c.name, out, out) - } - } -} - -// TestQuotaBackoffRecordsConsecutiveFailures covers the recording guard through -// the running plugin, because that part IS observable from outside. -// -// The guard used to be `not HOST_MEMO[host]`, true only on a host's first -// failure. After that the memo held a table, the guard was false, nothing was -// written, and the streak stayed pinned at 1 forever — measured by reading back -// {at:... streak:1} after four consecutive failures. -// -// Only two things are asserted, because only two are reachable in one process: -// a first failure records streak 1, and a SKIP does not grow it. A skipped probe -// is not a failure, and a skip that counted would let a never-probed host grow -// without end and never be retried. -func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) { - srv := newCountingServer(t) - ps := newQuotaPlugins(t, srv.URL) - - // Configure every vendor that needs its own credentials, so no adapter is - // UNABLE to judge the host. That is a precondition, not decoration: a host - // judged while an adapter is unconfigured gets a PROVISIONAL memo, and a - // provisional memo deliberately never backs off (it would refuse the very - // probe that succeeds once the credential arrives). Without this the test - // would be measuring that rule instead of the ordinary backoff. - if err := ps.SetState("quota", map[string]interface{}{ - "prices": map[string]interface{}{"control_key": "ctrl"}, - }); err != nil { - t.Fatal(err) - } - - args := map[string]interface{}{ - "sources": []interface{}{map[string]interface{}{ - "name": "dead", "base_url": srv.URL + "/v1", "api_key": "k", - }}, - "name": "dead", - } - streak := func() float64 { - st, _ := ps.State("quota").(map[string]interface{}) - memo, _ := st["probe_memo"].(map[string]interface{}) - for _, v := range memo { - if e, ok := v.(map[string]interface{}); ok { - if f, ok := e["streak"].(float64); ok { - return f - } - } - } - t.Fatalf("no negative memo recorded: %#v", memo) - return 0 - } - - if _, err := ps.Call("quota", "collect_source", args); err != nil { - t.Fatal(err) - } - if got := streak(); got != 1 { - t.Fatalf("streak after the first failure = %.0f, want 1", got) - } - - before := srv.Hits() - if _, err := ps.Call("quota", "collect_source", args); err != nil { - t.Fatal(err) - } - if served := srv.Hits() - before; served != 0 { - t.Errorf("a host inside its backoff window still made %d requests — the "+ - "whole feature is to not make any", served) - } - if got := streak(); got != 1 { - t.Errorf("a SKIP changed the streak to %.0f — a skipped probe is not a failure", got) - } -} - -// TestQuotaMemoKeyIsTheHost pins the key the memo is written and read under. -// -// This is the foundation the whole memoisation rests on, and it was broken in the -// most total way possible: hostOf was written with string.gmatch and a capture -// group, gmatch yields whole matches rather than captures, and with `^` the -// iterator never advanced — so it returned nil and every lookup and every write -// went to HOST_MEMO[nil]. The memo therefore never returned anything for any -// host, and the negative memo, the TTL and the backoff on top of them were all -// dead code that looked alive. -// -// Measured on this gateway before the fix: hostOf(key) matched 0 of the 19 -// entries in production state. -// -// The check is on the shipped source plus its runtime behaviour, because the -// failure mode is invisible from outside — a memo that never matches anything -// behaves exactly like a memo that has nothing to remember. -func TestQuotaMemoKeyIsTheHost(t *testing.T) { - src := readShippedPlugin(t, "quota.lua") - if !strings.Contains(src, "string.match(u,") { - t.Error("hostOf does not use string.match — string.gmatch yields whole " + - "matches, so the capture group is ignored and the function returns nil") - } - - ps := quotaWithDecision(t) - for _, c := range []struct{ in, want string }{ - {"https://qijiar.top/v1", "qijiar.top"}, - {"https://api.deepseek.com", "api.deepseek.com"}, - {"http://127.0.0.1:19900", "127.0.0.1:19900"}, - {"https://s2a.frankhan.top:14514/v1", "s2a.frankhan.top:14514"}, - } { - out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": c.in}) - if err != nil { - t.Fatalf("hostOf(%s): %v", c.in, err) - } - mm, _ := out.(map[string]interface{}) - got, _ := mm["h"].(string) - if got != c.want { - t.Errorf("hostOf(%q) = %q, want %q", c.in, got, c.want) - } - if got == "" { - t.Errorf("hostOf(%q) returned nothing — every lookup would be "+ - "HOST_MEMO[nil]", c.in) - } - } - - // The same host under two paths must produce the SAME key, which is the whole - // reason the memo is keyed this way. - out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/a/v1"}) - if err != nil { - t.Fatal(err) - } - out2, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/b/v1"}) - if err != nil { - t.Fatal(err) - } - m1, _ := out.(map[string]interface{}) - m2, _ := out2.(map[string]interface{}) - if m1["h"] != m2["h"] { - t.Errorf("two paths on one host produced different keys: %v and %v — "+ - "they would never share a memo", m1["h"], m2["h"]) - } -} - -// --- helpers --- - -// readShippedPlugin returns the bundled source, so the tests exercise the file -// that actually ships rather than a copy that can drift from it. -func readShippedPlugin(t *testing.T, name string) string { - t.Helper() - src, err := ReadBundledPlugin(strings.TrimSuffix(name, ".lua")) - if err != nil { - t.Fatalf("read repo plugin %s: %v", name, err) - } - return src -} - -// quotaWithDecision loads quota.lua with its local backoffSeconds exposed as an -// export. The plugin's own exports list is appended to, so the export allowlist is -// satisfied exactly as it is in production. -func quotaWithDecision(t *testing.T) *Plugins { - t.Helper() - src := readShippedPlugin(t, "quota.lua") - shim := strings.Replace(src, "return PLUGIN", - "PLUGIN.exports[#PLUGIN.exports+1] = '__decision'\n"+ - // Wrapped, not exported raw: backoffSeconds takes (at, streak) - // positionally, and the test calls it with a table because that is the - // shape a Go caller can pass. Exporting the raw function made every - // assertion read `at = , streak = nil`, so the - // function took its "no streak" path and returned false for every input - // — which looked exactly like a backoff that never fires. - "PLUGIN.__decision = function(q) return { r = backoffSeconds(q.at, q.streak) } end\n"+ - "PLUGIN.exports[#PLUGIN.exports+1] = '__hostof'\n"+ - "PLUGIN.__hostof = function(q) return { h = hostOf(q.u) } end\n"+ - "return PLUGIN", 1) - if shim == src { - t.Fatal("could not inject the decision shim — quota.lua no longer ends with 'return PLUGIN'") - } - return newQuotaPluginsWith(t, shim, "127.0.0.1") -} - -// quotaSkip asks the plugin whether a host that failed `streak` times and was -// last heard from `age` seconds ago should be skipped. -func quotaSkip(t *testing.T, ps *Plugins, streak, age int) bool { - out, err := ps.Call("quota", "__decision", map[string]interface{}{ - "at": float64(time.Now().Unix() - int64(age)), - "streak": float64(streak), - }) - if err != nil { - t.Fatalf("decision: %v", err) - } - // A single-field table collapses to the bare value on encode, so both shapes - // mean the same thing here. - switch v := out.(type) { - case bool: - return v - case map[string]interface{}: - b, ok := v["r"].(bool) - if !ok { - t.Fatalf("decision returned %T, not a boolean: %#v", v["r"], v) - } - return b - default: - t.Fatalf("decision returned %T (%v), not a boolean", out, out) - return false - } -} - -// newQuotaPlugins boots the shipped quota.lua against a temp plugin dir. -func newQuotaPlugins(t *testing.T, baseURL string) *Plugins { - t.Helper() - return newQuotaPluginsWith(t, readShippedPlugin(t, "quota.lua"), baseURL) -} - -func newQuotaPluginsWith(t *testing.T, src, baseURL string) *Plugins { - t.Helper() - dir := t.TempDir() - pluginDir := filepath.Join(dir, "plugins") - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - t.Fatal(err) - } - vm := NewVM(filepath.Join(dir, "adapters")) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(vm.Stop) - ps := NewPlugins(vm, pluginDir) - t.Cleanup(ps.Close) - ps.SetOutboundHosts([]string{"127.0.0.1"}) - if err := os.WriteFile(filepath.Join(pluginDir, "quota.lua"), []byte(src), 0o644); err != nil { - t.Fatal(err) - } - if err := ps.LoadDir(); err != nil { - t.Fatal(err) - } - p := ps.find("quota") - if p == nil { - t.Fatal("quota plugin did not load") - } - if p.LoadError != "" { - t.Fatalf("quota plugin failed to compile: %s", p.LoadError) - } - _ = baseURL - return ps -} - -// countingServer answers 404 on every path and counts what it is asked, so a test -// can tell "the plugin skipped" (no requests) from "the plugin probed and found -// nothing" (requests, no data). -// -// It used to answer 200 with a non-balance body, and the commandcode adapter -// accepted that — so the host was memoised as a SUCCESS and the negative path was -// never reached. The test then failed for the right reason, and would have -// passed for the wrong one had the assertion been looser. -type countingServer struct { - *httptest.Server - hits int -} - -func (c *countingServer) Hits() int { return c.hits } - -func newCountingServer(t *testing.T) *countingServer { - t.Helper() - cs := &countingServer{} - cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cs.hits++ - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"error":"not found"}`)) - })) - t.Cleanup(cs.Close) - return cs -} - -// TestQuotaDueSourcesAndMemoRecordCoverTheShardGap pins the split that makes the -// backoff work at all under sharding. -// -// The conflict, measured on a live 20-source gateway: -// - probing is network-bound and Plugins.Call holds the plugin mutex for the -// whole Lua call, so /call/ serialises (29s) -// - so the page uses ShardCall — and a shard has its own PLUGIN.state -// - and the backoff lives in PLUGIN.state: fanout left the memo at 0 entries, -// so dead hosts were re-probed forever -// -// The resolution is to put the DECISION and the RECORDING back on the shared VM -// (microseconds, no network) and shard only the network wait. These two exports -// are that seam, and without them the fanout path silently defeats the backoff. -func TestQuotaDueSourcesAndMemoRecordCoverTheShardGap(t *testing.T) { - src := readShippedPlugin(t, "quota.lua") - for _, want := range []string{"due_sources", "memo_record"} { - if !strings.Contains(src, want) { - t.Errorf("quota.lua does not export %s — a sharded pass cannot see the "+ - "backoff state, nor leave any behind", want) - } - if !strings.Contains(src, `"`+want+`"`) { - t.Errorf("%s is not listed in PLUGIN.exports, so the page cannot call it", want) - } - } - // due_sources must not probe. If it did, the whole point of asking it first - // would be lost: the cost would be paid before any shard runs. - if strings.Contains(src, "due_sources = function(payload)") && - strings.Contains(src, "due_sources") { - // The body is small; check it does not call collectSource. - i := strings.Index(src, "due_sources = function(payload)") - j := strings.Index(src[i:], "\nend\n") - if j > 0 { - body := src[i : i+j] - if strings.Contains(body, "collectSource(") { - t.Error("due_sources calls collectSource — the filter must make no " + - "network request, or it costs the same as probing everything") - } - } - } -} - -// TestQuotaMemoRecordGrowsTheStreakFromShards covers the recording half: a host -// that failed inside a shard must end up in the SHARED memo with a streak, or the -// next refresh has nothing to back off from. -func TestQuotaMemoRecordGrowsTheStreakFromShards(t *testing.T) { - srv := newCountingServer(t) - ps := newQuotaPlugins(t, srv.URL) - host := hostOnly(srv.URL) - - read := func() map[string]interface{} { - st, _ := ps.State("quota").(map[string]interface{}) - memo, _ := st["probe_memo"].(map[string]interface{}) - return memo - } - - // A shard reports a host that answered with nothing. - for i := 1; i <= 3; i++ { - out, err := ps.Call("quota", "memo_record", map[string]interface{}{ - "results": []interface{}{ - map[string]interface{}{"host": srv.URL + "/v1"}, - }, - }) - if err != nil { - t.Fatalf("memo_record %d: %v", i, err) - } - if out != true { - t.Fatalf("memo_record returned %#v", out) - } - } - memo := read() - e, ok := memo[host].(map[string]interface{}) - if !ok { - t.Fatalf("memo has no entry for %s: %#v", host, memo) - } - if got, _ := e["streak"].(float64); got != 3 { - t.Errorf("streak after three shard failures = %.0f, want 3", got) - } - - // A host that answers is recorded as a positive memo, which resets the streak. - if _, err := ps.Call("quota", "memo_record", map[string]interface{}{ - "results": []interface{}{ - map[string]interface{}{"host": srv.URL + "/v1", "vendor": "deepseek"}, - }, - }); err != nil { - t.Fatal(err) - } - if got := read()[host]; got != "deepseek" { - t.Errorf("a host that answered is memoised as %#v, want the vendor name — "+ - "the streak must not survive a success", got) - } -} - -// TestQuotaDueSourcesSkipsBackedOffHosts is the behaviour the page depends on: -// the filter must keep a backed-off host out of the sharded set. -func TestQuotaDueSourcesSkipsBackedOffHosts(t *testing.T) { - srv := newCountingServer(t) - ps := newQuotaPlugins(t, srv.URL) - host := hostOnly(srv.URL) - - // One failure, which is enough to put the host inside its window. - if _, err := ps.Call("quota", "memo_record", map[string]interface{}{ - "results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}}, - }); err != nil { - t.Fatal(err) - } - - // A SECOND host, so the two cases are genuinely independent. The memo is keyed - // by host, so two paths on one host are one entry — using the same server here - // would have asserted that a per-host memo behaves per-path. - other := newCountingServer(t) - srcs := []interface{}{ - map[string]interface{}{"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k"}, - map[string]interface{}{"name": "fresh", "base_url": other.URL + "/v1", "api_key": "k"}, - } - out, err := ps.Call("quota", "due_sources", map[string]interface{}{"sources": srcs}) - if err != nil { - t.Fatal(err) - } - m, ok := out.(map[string]interface{}) - if !ok { - t.Fatalf("due_sources returned %#v", out) - } - due, _ := m["due"].([]interface{}) - backing, _ := m["backing_off"].([]interface{}) - if len(backing) != 1 { - t.Errorf("backing_off = %#v, want exactly the host that failed", backing) - } else if b, _ := backing[0].(map[string]interface{}); b["host"] != host { - t.Errorf("backing off %v, want %s — the key must be the host, or it will "+ - "never match on the next refresh", b["host"], host) - } - if len(due) != 1 { - t.Errorf("due = %#v, want only the unprobed host", due) - } else if d, _ := due[0].(map[string]interface{}); d["name"] != "fresh" { - t.Errorf("due contains %v, want fresh", d["name"]) - } - - // And it must make no network request — the whole saving depends on it. - if n := srv.Hits(); n != 0 { - t.Errorf("due_sources made %d requests; it must decide without probing", n) - } -} - -// hostOnly mirrors the plugin's own hostOf. Used only to CHECK what the plugin -// wrote — seeding a memo under a different key produces a memo the plugin never -// looks up, which cost this file a couple of rounds before it was noticed. -func hostOnly(baseURL string) string { - u := strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://") - if i := strings.Index(u, "/"); i >= 0 { - u = u[:i] - } - return u -} - -// TestQuotaBackoffRecordIsSkippedAndFlagged pins the shape the page depends on. -// -// Two things went wrong in the same field, and both were silent: -// -// 1. The back-off record had no `skipped` flag, so the page's -// `filter(r => !r.skipped)` let it through to the card renderer. That -// renderer matches on known fields, and a record carrying only a reason -// matches none — so it fell through to a default that said "有数据". -// Thirteen dead upstreams were rendered as though they had answered. -// 2. Once it did carry `skipped`, the page grouped skipped sources by reading -// the REASON TEXT. Translating that string would have silently merged -// "backing off" into "exposes no balance endpoint" — a different claim -// about the upstream. The grouping keys on an explicit flag. -func TestQuotaBackoffRecordIsSkippedAndFlagged(t *testing.T) { - srv := newCountingServer(t) - ps := newQuotaPlugins(t, srv.URL) - - // One failure, enough to put the host inside its window. - if _, err := ps.Call("quota", "memo_record", map[string]interface{}{ - "results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}}, - }); err != nil { - t.Fatal(err) - } - - out, err := ps.Call("quota", "due_sources", map[string]interface{}{ - "sources": []interface{}{map[string]interface{}{ - "name": "dead", "base_url": srv.URL + "/v1", "api_key": "k", - }}, - }) - if err != nil { - t.Fatal(err) - } - m, _ := out.(map[string]interface{}) - backing, _ := m["backing_off"].([]interface{}) - if len(backing) != 1 { - t.Fatalf("backing_off = %#v, want one record", m["backing_off"]) - } - rec, _ := backing[0].(map[string]interface{}) - if rec["skipped"] != true { - t.Errorf("back-off record has skipped=%v — the page filters cards on this "+ - "field, and without it the record is rendered as a source with data", - rec["skipped"]) - } - if rec["backing_off"] != true { - t.Errorf("back-off record has no explicit backing_off flag; the page groups " + - "by it instead of by reason text, so translating the message cannot " + - "merge this with 'no balance endpoint'") - } - if s, _ := rec["reason"].(string); s == "" { - t.Error("back-off record carries no reason; the card renders empty") - } -} - -// TestQuotaRenderCallSitesPassRecordsNotNames guards a bug that only appeared -// because ONE of two identical call sites was fixed. -// -// render() groups skipped sources by the backing_off flag. A call site that maps -// the records to their names first ("skipped.map(r => r.name)") therefore hands -// render an array of strings: the flag is gone, every entry falls into the -// "exposes no balance endpoint" bucket, and the footer lists N unnamed sources. -// -// The first fix changed only the refresh path. The snapshot path kept mapping to -// names, and the symptom was a footer reading "未列出 13 个源:、、、、…" — which -// reads like a rendering bug rather than a contract mismatch between two call -// sites of one function. -func TestQuotaRenderCallSitesPassRecordsNotNames(t *testing.T) { - src := readShippedPlugin(t, "quota.lua") - if strings.Contains(src, ".map(function(r){ return r.name; })") || - strings.Contains(src, ".map(function(r){return r.name;})") { - // A benign occurrence would be fine, but there is no other reason for a - // skipped list to be reduced to names, so treat any as the bug. - for _, line := range strings.Split(src, "\n") { - if strings.Contains(line, "skipped") && strings.Contains(line, ".map(") && - strings.Contains(line, "r.name") { - t.Errorf("a skipped list is mapped to names before reaching render(): %s\n"+ - "render() groups on the backing_off flag, which a name cannot carry", - strings.TrimSpace(line)) - } - } - } - // Both call sites must exist and both must hand over records. - if n := strings.Count(src, ".filter(function(r){return r.skipped;})") + - strings.Count(src, ".filter(function(r){ return r && r.skipped; })"); n < 2 { - t.Errorf("found %d skipped-list filters, want 2 (snapshot and refresh paths)", n) - } -} diff --git a/internal/lua/quota_opendesign_test.go b/internal/lua/quota_opendesign_test.go deleted file mode 100644 index 0dc3f23..0000000 --- a/internal/lua/quota_opendesign_test.go +++ /dev/null @@ -1,650 +0,0 @@ -package lua - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -// The OpenDesign / Vela adapter is the first quota probe that needs a credential -// the source itself does not carry. Inference runs on the source's `api_key` -// (the runtime key); the two billing endpoints on amr-api.open-design.ai only -// accept the `controlKey` the desktop app receives at sign-in. -// -// That asymmetry is why these tests exist: the obvious wrong implementation — -// "reuse the source's api_key like every other adapter" — returns 401 against -// the live service, and a test that only stubs a happy response would never -// notice. -// -// Live responses this fixture copies (both 200, ~1.3s): -// -// GET https://amr-api.open-design.ai/api/v1/wallet/balance -// GET https://amr-api.open-design.ai/api/v1/billing/summary -const ( - odWallet = `{"balanceUsd":"15.7062","expiresAt":"2026-10-08T23:01:06.662Z",` + - `"updatedAt":"2026-10-06T08:02:20.167Z","billingScopeVersion":2,` + - `"workspaceId":"h7cvgf86l79yy3cpmsbmjfxu",` + - `"workspaceMemberId":"uxqzu1rrtwsmzfpwugkcuany"}` - - odSummary = `{"balanceUsd":"15.7062","creditsPerUsd":10000,` + - `"balances":{"subscriptionCredits":"152062","rechargeCredits":"5000",` + - `"totalAvailableCredits":"157062"},"subscriptionCreditsUsd":"15.2062",` + - `"rechargeCreditsUsd":"0.5000","totalAvailableCreditsUsd":"15.7062",` + - `"membershipTier":"plus","billingInterval":"monthly",` + - `"subscriptionStatus":"active","subscriptionEntitlementStatus":"active",` + - `"subscriptionCancelAtPeriodEnd":false,` + - `"subscriptionCurrentPeriodStart":"2026-10-05T23:01:54.000Z",` + - `"subscriptionCurrentPeriodEnd":"2026-11-05T23:01:54.000Z",` + - `"pendingSubscriptionChange":null,"firstMonthIntroEligible":false,` + - `"availableActions":["subscription_checkout","billing_portal"],` + - `"totalRechargedUsd":"0.5000","totalConsumedUsd":"4.7938",` + - `"todayConsumedUsd":"0.0036","rechargeCount":1,"usageCount":2982,` + - `"updatedAt":"2026-10-06T14:55:00.407Z"}` -) - -// odServer stands in for amr-api. It records the Authorization header it saw on -// the two BILLING routes so a test can assert WHICH credential the plugin sent -// — the whole point of this adapter. -// -// Only the billing paths are recorded. The other three adapters derive their -// paths from the source's base_url, not from api_base, so in these tests they -// are pointed at amr-link and refused by the outbound allowlist before they can -// reach this server. Recording every request would make the "did it probe at -// all?" assertion depend on that coincidence; recording the billing routes makes -// it mean exactly what it says. -func odServer(t *testing.T, seenAuth *[]string, wallet, summary string, wStatus, sStatus int) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasSuffix(r.URL.Path, "/wallet/balance"): - if seenAuth != nil { - *seenAuth = append(*seenAuth, r.Header.Get("Authorization")) - } - w.WriteHeader(wStatus) - _, _ = w.Write([]byte(wallet)) - case strings.HasSuffix(r.URL.Path, "/billing/summary"): - if seenAuth != nil { - *seenAuth = append(*seenAuth, r.Header.Get("Authorization")) - } - w.WriteHeader(sStatus) - _, _ = w.Write([]byte(summary)) - default: - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"error":"not found"}`)) - } - })) -} - -// odPlugins boots a real VM with the shipped quota.lua (not a copy) and returns -// the loaded Plugins. -// odPlugins boots the shipped quota.lua against a temp plugin dir. -// -// NOTE on what these tests can and cannot reach: the backoff window is read from -// the WALL CLOCK, so nothing here observes recovery of an aged negative memo — -// reaching that branch would mean sleeping 1800s. The aged-entry path is covered -// out of band, by seeding a negative memo with an old `at` (`.probe/trace_memo.py`, -// step 10) and watching the same code recover. What IS asserted here is the entry's -// SHAPE, which is what the read side keys its decisions on. -func odPlugins(t *testing.T) (*Plugins, string) { - t.Helper() - dir := t.TempDir() - pluginDir := filepath.Join(dir, "plugins") - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - t.Fatal(err) - } - vm := NewVM(filepath.Join(dir, "adapters")) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(vm.Stop) - - src := readShippedPlugin(t, "quota.lua") - if err := os.WriteFile(filepath.Join(pluginDir, "quota.lua"), []byte(src), 0o644); err != nil { - t.Fatal(err) - } - ps := NewPlugins(vm, pluginDir) - t.Cleanup(ps.Close) - // Set before LoadDir: the allowlist is consulted when the plugin makes a - // request, and the empty default denies everything. - ps.SetOutboundHosts([]string{"127.0.0.1"}) - if err := ps.LoadDir(); err != nil { - t.Fatal(err) - } - p := ps.find("quota") - if p == nil { - t.Fatal("quota plugin not found") - } - if p.LoadError != "" { - t.Fatalf("quota.lua failed to compile: %s", p.LoadError) - } - return ps, src -} - -// collectOpenDesign drives the plugin's own collect_source for a source that -// looks like vela. The plugin iterates ADAPTERS in order and picks the host's -// remembered vendor, so the first adapter that answers is the one under test. -func collectOpenDesign(t *testing.T, ps *Plugins, base string) map[string]interface{} { - t.Helper() - out, err := ps.Call("quota", "collect_source", map[string]interface{}{ - "name": "vela", - "sources": []interface{}{ - map[string]interface{}{ - "name": "vela", - "base_url": base, - "api_key": "sk-amr-runtime-key-must-not-be-sent-to-billing", - }, - }, - }) - if err != nil { - t.Fatalf("collect_source failed: %v", err) - } - rep, _ := out.(map[string]interface{}) - return rep -} - -// TestQuotaOpenDesignUsesControlKeyNotRuntimeKey is the reason this adapter -// cannot be written like the others. -// -// Mutation: replace `request(api .. "/wallet/balance", ck, ...)` with the -// function's `key` parameter (`request(api .. "/wallet/balance", key, ...)`) -// and this fails — the server records the runtime key. -func TestQuotaOpenDesignUsesControlKeyNotRuntimeKey(t *testing.T) { - var seenAuth []string - srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200) - defer srv.Close() - - ps, _ := odPlugins(t) - // The plugin's config table is `prices`; SetState routes a `prices` key - // there rather than into accumulated state. - if err := ps.SetState("quota", map[string]interface{}{ - "prices": map[string]interface{}{ - "control_key": "vela_ctrl_TESTKEY", - "api_base": srv.URL + "/api/v1", - }, - }); err != nil { - t.Fatalf("SetState: %v", err) - } - - rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1") - if rep == nil { - t.Fatal("no report produced") - } - if rep["vendor"] != "opendesign" { - t.Fatalf("wrong adapter answered: vendor=%v (report=%v)", rep["vendor"], rep) - } - for _, got := range seenAuth { - if strings.Contains(got, "runtime-key") { - t.Fatalf("the source's api_key was sent to the billing endpoint (%q); "+ - "amr-api rejects it with 401 — the control key is a separate credential", got) - } - if got != "Bearer vela_ctrl_TESTKEY" { - t.Fatalf("billing request carried %q, want the configured control key", got) - } - } - if len(seenAuth) == 0 { - t.Fatal("no request reached the server: the adapter never probed") - } -} - -// TestQuotaOpenDesignSkipsWithoutControlKey pins the failure mode that made the -// previous incarnation of this probe useless: with no credential the adapter -// must SKIP (not error, not report a zero balance). An error would put the -// source in the negative memo and file it under "probe failed"; a zero balance -// would tell the operator the account is empty. -// -// This test originally left api_base unset, which sent the probe to the REAL -// amr-api host. Every path then failed on the network and returned nil, so the -// test passed for the wrong reason — and a mutation that deleted the credential -// guard survived. Pointing api_base at a local stub makes the server reachable, -// so the only thing that can stop the request is the guard itself. -func TestQuotaOpenDesignSkipsWithoutControlKey(t *testing.T) { - var seenAuth []string - srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200) - defer srv.Close() - - ps, _ := odPlugins(t) - // api_base set, control_key deliberately NOT set. - if err := ps.SetState("quota", map[string]interface{}{ - "prices": map[string]interface{}{"api_base": srv.URL + "/api/v1"}, - }); err != nil { - t.Fatal(err) - } - - rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1") - if rep == nil { - t.Fatal("no report produced") - } - // The guard must come BEFORE the network. A reachable server that is never - // asked anything is the only evidence that the credential check ran at all. - if len(seenAuth) != 0 { - t.Fatalf("the adapter probed the billing host with no control key configured "+ - "(Authorization: %q); an empty bearer is not a credential and the "+ - "response cannot be trusted", seenAuth) - } - if rep["skipped"] != true { - t.Fatalf("skipped = %#v, want true", rep["skipped"]) - } - reason, _ := rep["reason"].(string) - if !strings.Contains(reason, "control_key") { - t.Fatalf("reason = %q, want it to name the missing control_key so the "+ - "operator knows what to go and configure", reason) - } - // Whatever adapter answers, it must not be a fabricated opendesign balance. - if rep["vendor"] == "opendesign" { - t.Fatalf("opendesign reported a balance with no control key configured: %v", rep) - } -} - -// TestQuotaOpenDesignReportsBalanceAndSpend checks the fields the operator -// actually reads. The vendor sends these as JSON STRINGS ("15.7062"), so a -// naive type assertion to number would drop every one of them. -func TestQuotaOpenDesignReportsBalanceAndSpend(t *testing.T) { - srv := odServer(t, nil, odWallet, odSummary, 200, 200) - defer srv.Close() - - ps, _ := odPlugins(t) - if err := ps.SetState("quota", map[string]interface{}{ - "prices": map[string]interface{}{ - "control_key": "vela_ctrl_TESTKEY", - "api_base": srv.URL + "/api/v1", - }, - }); err != nil { - t.Fatal(err) - } - rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1") - if rep == nil || rep["vendor"] != "opendesign" { - t.Fatalf("expected an opendesign report, got %v", rep) - } - - want := map[string]float64{ - "balance": 15.7062, - "subscription_usd": 15.2062, - "recharge_usd": 0.5000, - "today_used": 0.0036, - "period_used": 4.7938, - } - for k, exp := range want { - got, ok := rep[k].(float64) - if !ok { - t.Fatalf("%s missing or not a number: %#v (full report %v)", k, rep[k], rep) - } - if got != exp { - t.Fatalf("%s = %v, want %v", k, got, exp) - } - } - if rep["credits_per_usd"] != float64(10000) { - t.Fatalf("credits_per_usd = %v, want 10000 (used for the credit split)", rep["credits_per_usd"]) - } - if rep["tier"] != "plus" || rep["status"] != "active" { - t.Fatalf("tier/status = %v/%v, want plus/active", rep["tier"], rep["status"]) - } - if rep["balance_expires_at"] != "2026-10-08T23:01:06.662Z" { - t.Fatalf("balance_expires_at = %v — a balance that expires silently is "+ - "worse than one that warns", rep["balance_expires_at"]) - } - // usageCount arrives as a JSON number, not a string, and must survive too. - if rep["usage_count"] != float64(2982) { - t.Fatalf("usage_count = %#v, want 2982", rep["usage_count"]) - } -} - -// TestQuotaOpenDesignHasNoFabricatedPeriodWindow pins the deliberately-absent -// field. `vela billing workspace-snapshot` answers billingState:"free" with NO -// codingPlan.windows for this account, so there is no period allowance to show. -// -// Mutation: add `rep.windows = {{...}}` (or any synthesized window) and this -// fails. The page renders windows as "X% remaining", so a fabricated one is a -// confident lie about the account. -func TestQuotaOpenDesignHasNoFabricatedPeriodWindow(t *testing.T) { - srv := odServer(t, nil, odWallet, odSummary, 200, 200) - defer srv.Close() - - ps, _ := odPlugins(t) - if err := ps.SetState("quota", map[string]interface{}{ - "prices": map[string]interface{}{ - "control_key": "vela_ctrl_TESTKEY", - "api_base": srv.URL + "/api/v1", - }, - }); err != nil { - t.Fatal(err) - } - rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1") - if rep == nil || rep["vendor"] != "opendesign" { - t.Fatalf("expected an opendesign report, got %v", rep) - } - if w, ok := rep["windows"]; ok && w != nil { - t.Fatalf("windows = %#v, but this account has no Coding Plan "+ - "(billingState=free) — a synthesized window renders as a "+ - "percentage remaining that does not exist", w) - } -} - -// TestQuotaOpenDesignRejectedControlKeyIsAnError guards the diagnosis. A 401 -// means the credential is wrong — a fact the operator must fix — so it must not -// be filed under "skip" alongside "this vendor has no balance endpoint". The two -// produce the same blank card but wildly different next actions. -// -// Two details make the test actually exercise the rule instead of passing by -// accident, and both were found by a mutation surviving: -// -// 1. The memo is pre-seeded so openDesign runs FIRST. The memo remembers which -// vendor answered last and that vendor is tried first; once a host has ever -// answered through openDesign it runs first forever after. With the default -// order (openDesign last) its 401 IS the last verdict, so even a plain -// "last error wins" rule passes. -// 2. The other three adapters must ANSWER (404), not fail. They read `base`, not -// `api_base`, so the source base_url points at the same stub — otherwise they -// all error out on an unreachable host and the final verdict is an error -// regardless of the rule being tested. -func TestQuotaOpenDesignRejectedControlKeyIsAnError(t *testing.T) { - srv := odServer(t, nil, `{"error":"unauthenticated"}`, `{"error":"unauthenticated"}`, - http.StatusUnauthorized, http.StatusUnauthorized) - defer srv.Close() - - ps, _ := odPlugins(t) - // Host key must match what hostOf(base) yields for the source below. - src := map[string]interface{}{ - "name": "vela", "base_url": srv.URL, "api_key": "k", - } - if err := ps.SetState("quota", map[string]interface{}{ - "prices": map[string]interface{}{ - "control_key": "vela_ctrl_WRONG", - "api_base": srv.URL + "/api/v1", - }, - // Flat, NOT nested under a "state" key: SetState pulls `prices` out and - // stores the REST of the payload as the plugin's state. Nesting it would - // store {state: {probe_memo: ...}} and the memo would never be read. - "probe_memo": map[string]interface{}{ - hostOnly(srv.URL): "opendesign", - }, - }); err != nil { - t.Fatal(err) - } - - out, err := ps.Call("quota", "collect_source", map[string]interface{}{ - "name": "vela", "sources": []interface{}{src}, - }) - if err != nil { - t.Fatal(err) - } - rep, _ := out.(map[string]interface{}) - if rep == nil { - t.Fatal("no report produced") - } - if rep["vendor"] == "opendesign" { - t.Fatalf("a rejected control key must not produce a balance: %v", rep) - } - // The plugin reports a credential failure as an error, which the page renders - // as a red "读取失败" row. Filing it as a skip instead shows the same blank - // card as "this vendor has no balance endpoint" — and sends the operator to - // investigate the vendor rather than re-run the login. - if rep["skipped"] == true { - t.Fatalf("a rejected control key was reported as a SKIP: %v — the other three "+ - "adapters answered 404 for this host, so the final verdict is a skip unless "+ - "a rejected credential outranks it. The operator would go looking at the "+ - "vendor instead of re-running the login.", rep) - } - msg, _ := rep["error"].(string) - if !strings.Contains(msg, "rejected") { - t.Fatalf("error = %q, want it to name the rejected credential so the "+ - "operator knows to re-run the login rather than to investigate the vendor", msg) - } -} - -// TestQuotaOpenDesignDoesNotDefaultMissingRate pins the "do not invent an -// exchange rate" rule. creditsPerUsd is the only thing that converts the credit -// breakdown into dollars; when it is absent the vendor's own *Usd fields still -// give a correct balance, so the card must render with the split missing and -// FLAG it, not divide by a remembered 10000. -func TestQuotaOpenDesignDoesNotDefaultMissingRate(t *testing.T) { - // Same summary with creditsPerUsd removed. - noRate := strings.Replace(odSummary, `"creditsPerUsd":10000,`, "", 1) - if strings.Contains(noRate, "creditsPerUsd") { - t.Fatal("fixture still carries creditsPerUsd") - } - srv := odServer(t, nil, odWallet, noRate, 200, 200) - defer srv.Close() - - ps, _ := odPlugins(t) - if err := ps.SetState("quota", map[string]interface{}{ - "prices": map[string]interface{}{ - "control_key": "vela_ctrl_TESTKEY", - "api_base": srv.URL + "/api/v1", - }, - }); err != nil { - t.Fatal(err) - } - rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1") - if rep == nil || rep["vendor"] != "opendesign" { - t.Fatalf("expected an opendesign report, got %v", rep) - } - // The balance is authoritative and comes straight from the vendor. - if rep["balance"] != 15.7062 { - t.Fatalf("balance = %#v, want 15.7062 (it must not depend on the rate)", rep["balance"]) - } - if rep["credits_rate_missing"] != true { - t.Fatalf("credits_rate_missing = %#v, want true: the page must show that "+ - "the credit split is unavailable rather than a silent zero", rep["credits_rate_missing"]) - } - if v, ok := rep["credits_per_usd"]; ok && v != nil { - t.Fatalf("credits_per_usd = %#v with no rate in the response — a defaulted "+ - "rate produces confidently wrong dollar amounts", v) - } - // subscription_usd/recharge_usd come from the vendor's own USD fields, so - // they are still correct and must still be present. - if rep["subscription_usd"] != 15.2062 { - t.Fatalf("subscription_usd = %#v, want 15.2062 from the vendor's own field", - rep["subscription_usd"]) - } -} - -// TestQuotaOpenDesignHostRewritingIsUsed keeps the amr-link -> amr-api default -// working. Without it the adapter probes the inference host, which serves no -// billing route at all, and every OpenDesign source silently skips. -// -// Asserted on the exported derivation rather than on the source text or on a -// failed probe's error message. The source-text version pinned the SPELLING of -// the rule: it went red the moment the rule was tightened from an `amr-link.` -// prefix to a full hostname, while the behaviour was unchanged. The -// error-message version inferred "which host did we ask?" from an allowlist -// refusal, which stops working the moment any adapter earlier in the chain also -// fails — and one of them always does. -func TestQuotaOpenDesignHostRewritingIsUsed(t *testing.T) { - ps, _ := odPlugins(t) - cases := []struct{ in, want string }{ - // The real production shape: base_url carries a path. - {"https://amr-link.open-design.ai/v1", "https://amr-api.open-design.ai/api/v1"}, - // And without one, so the derivation cannot depend on the path being there. - {"https://amr-link.open-design.ai", "https://amr-api.open-design.ai/api/v1"}, - // A trailing slash must not produce a double slash. - {"https://amr-link.open-design.ai/v1/", "https://amr-api.open-design.ai/api/v1"}, - } - for _, c := range cases { - out, err := ps.Call("quota", "resolve_api_base", map[string]interface{}{ - "base_url": c.in, - }) - if err != nil { - t.Fatalf("resolve_api_base(%q): %v", c.in, err) - } - rep, _ := out.(map[string]interface{}) - if got, _ := rep["api_base"].(string); got != c.want { - t.Errorf("resolve_api_base(%q) = %q, want %q — the probe would go to the "+ - "inference host, which serves no billing route, and the source would "+ - "silently stay unlisted", c.in, got, c.want) - } - } - - // A lookalike host must NOT be rewritten into an OpenDesign address: an - // `amr-link.` prefix rule would turn amr-link.example.com into amr-api..., - // sending an unrelated provider's probe to a host that is not its own. - out, err := ps.Call("quota", "resolve_api_base", map[string]interface{}{ - "base_url": "https://amr-link.example.com/v1", - }) - if err != nil { - t.Fatal(err) - } - rep, _ := out.(map[string]interface{}) - if got, _ := rep["api_base"].(string); got != "https://amr-link.example.com/api/v1" { - t.Errorf("a lookalike host was rewritten to %q", got) - } - - // An explicit override wins (and is trimmed), so an operator can point at a - // moved host without waiting for a code change. - out, err = ps.Call("quota", "resolve_api_base", map[string]interface{}{ - "base_url": "https://amr-link.open-design.ai/v1", - "api_base": "https://amr-api.example.net/api/v1/", - }) - if err != nil { - t.Fatal(err) - } - rep, _ = out.(map[string]interface{}) - if got, _ := rep["api_base"].(string); got != "https://amr-api.example.net/api/v1" { - t.Errorf("api_base override not honoured (or not trimmed): %q", got) - } -} - -// TestQuotaUnreadyAdapterDoesNotSuppressAConfiguredHost is the end-to-end half of -// the unready rule, and it reproduces two bugs seen on the live plugin. -// -// (1) STALE READ. The memo is applied at the top of collectSource and the table -// it comes from is only updated by the WRITE side, which runs at the very end. -// Keying the backoff on `memo_entry_exists` therefore asks a question that is -// true on everything except the first probe of the oldest entry, and the plugin -// describes it to the operator as "连续探测失败 1 次" — an invented streak. Live -// trace: unset key → memo → key set → probes refused, memo untouched, forever. -// Backing off must key on the recorded STREAK, not on the entry existing. -// -// (2) EMPTY `unready` SET. Lua drops empty tables, so an answer from every -// adapter writes a negative with no `unready` field — indistinguishable, by -// truthiness, from a provisional one whose set happens to be empty. Reading it as -// provisional made an ordinary streak into a non-backing-off entry, which is the -// other half of the same live symptom. -// -// It is NOT fixable by "do not memoise a skip": on a real OpenDesign host the -// other three adapters answer 404 ("no dashboard here"), which is durable and -// worth remembering, so the verdict would be recorded either way. The memo has to -// carry WHICH vendors were unable to judge the host, and the read side has to -// retire that record once they can. -func TestQuotaUnreadyAdapterDoesNotSuppressAConfiguredHost(t *testing.T) { - var seenAuth []string - srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200) - defer srv.Close() - - ps, _ := odPlugins(t) - args := map[string]interface{}{ - "name": "vela", - "sources": []interface{}{map[string]interface{}{ - "name": "vela", "base_url": srv.URL + "/v1", "api_key": "k", - }}, - } - - // Step 1: no control_key. openDesign cannot judge the host, and no billing - // request goes anywhere. - out, err := ps.Call("quota", "collect_source", args) - if err != nil { - t.Fatal(err) - } - rep, _ := out.(map[string]interface{}) - if rep["skipped"] != true { - t.Fatalf("expected a skip with no control key configured: %v", rep) - } - if len(seenAuth) != 0 { - t.Fatalf("an unconfigured adapter made a request: %q", seenAuth) - } - - // Step 2: configure the key. The very next probe must reach the upstream. It - // is inside the backoff window (minutes of wall clock) while this runs in - // microseconds — so a host refused now is refused for the whole window in - // production, which is the bug, not a timing accident. - if err := ps.SetState("quota", map[string]interface{}{ - "prices": map[string]interface{}{ - "control_key": "vela_ctrl_TESTKEY", - "api_base": srv.URL + "/api/v1", - }, - }); err != nil { - t.Fatal(err) - } - out, err = ps.Call("quota", "collect_source", args) - if err != nil { - t.Fatal(err) - } - rep, _ = out.(map[string]interface{}) - if rep["vendor"] != "opendesign" || rep["skipped"] == true { - t.Fatalf("expected an opendesign balance once configured, got %v — the host "+ - "is still suppressed by the provisional memo it earned while the adapter "+ - "had no credential", rep) - } - if len(seenAuth) == 0 { - t.Fatal("no request reached the billing endpoint after the control key was " + - "supplied") - } -} - -// TestQuotaUnreadyStreakDoesNotBlockAConfiguredHost covers the second half of the -// same live symptom, and it needs its own server because the first half ends with a -// SUCCESS (which overwrites the memo). -// -// The read side backs a host off by looking up `HOST_MEMO[host]`, a table the -// WRITE side only fills in at the very end of the call. Two ways that goes wrong: -// -// - backing off on "an entry exists" instead of on its streak — the entry always -// exists, so the host is refused forever after its first negative -// - reading an `unready` set that is absent (Lua drops empty tables when every -// adapter answered) as if it were a provisional one, which DISABLES the backoff -// for exactly the hosts that most need it -// -// Both are about the shape of the entry rather than about the vendor, so they are -// asserted on the entry the probe actually left behind. -func TestQuotaUnreadyStreakDoesNotBlockAConfiguredHost(t *testing.T) { - // Every billing route 404s: no vendor has a dashboard for this host, which is - // the durable negative the memo exists to remember. - srv := odServer(t, nil, `{}`, `{}`, 404, 404) - defer srv.Close() - - ps, _ := odPlugins(t) - if err := ps.SetState("quota", map[string]interface{}{ - "prices": map[string]interface{}{ - "control_key": "vela_ctrl_TESTKEY", - "api_base": srv.URL + "/api/v1", - }, - }); err != nil { - t.Fatal(err) - } - args := map[string]interface{}{ - "name": "vela", - "sources": []interface{}{map[string]interface{}{ - "name": "vela", "base_url": srv.URL + "/v1", "api_key": "k", - }}, - } - for i := 0; i < 2; i++ { - if _, err := ps.Call("quota", "collect_source", args); err != nil { - t.Fatalf("probe %d: %v", i+1, err) - } - } - - st, _ := ps.State("quota").(map[string]interface{}) - memo, _ := st["probe_memo"].(map[string]interface{}) - // The host key is derived from base_url, so find the single entry. - if len(memo) != 1 { - t.Fatalf("expected exactly one memo entry, got %v", memo) - } - var entry map[string]interface{} - for _, v := range memo { - entry, _ = v.(map[string]interface{}) - } - if entry == nil { - t.Fatalf("the negative result was not recorded at all: %v", memo) - } - // The durable/provisional distinction is the entry's SHAPE. A first probe of a - // host no vendor can report on must produce a durable entry: with the empty - // `unready` set stamped on unconditionally, Lua's dropping of empty tables - // means the reader and the writer disagree about what an absent field means, - // and a host that most needs backing off is the one that never gets it. - if _, provisional := entry["unready"]; provisional { - t.Fatalf("every adapter judged this host, so the entry must not be "+ - "provisional (an absent `unready` is what makes it durable): %v", entry) - } -} diff --git a/internal/lua/store_dir_isolation_test.go b/internal/lua/store_dir_isolation_test.go deleted file mode 100644 index 2c22102..0000000 --- a/internal/lua/store_dir_isolation_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package lua - -import ( - "os" - "strings" - "testing" -) - -// **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。 -// -// 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底: -// 环境变量 → settings → **读 /etc/modelrouter/config.yaml 的 plugin_dir** → -// 硬编码 /etc/modelrouter/plugins。测试台没设环境变量时,后两级直接命中**生产**: -// -// 测试插件目录 = /tmp/TestXXX/002/plugins -// storeDir 结果 = /etc/modelrouter/plugins ← 实测 -// -// 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量 -// `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool: -// /etc/modelrouter/plugins/.channels-store.json 从 1730 变 1986 字节, -// 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。 -// -// 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产 -// .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。 -func TestStoreDirIsolatedInTests(t *testing.T) { - _, ps, pdir := runtimePlugins(t) - src, err := ReadBundledPlugin("channels") - 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/modelrouter/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") { - t.Fatalf("storeDir 指向了**生产**目录 %q —— 测试会覆盖生产账号池\n"+ - "(真实事故:13 个账号被覆盖成 0 个)。修法:runtimePlugins 设\n"+ - "MODELROUTER_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("MODELROUTER_PLUGIN_DIR") == "" { - t.Error("runtimePlugins 必须设 MODELROUTER_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)") - } -} diff --git a/internal/lua/toolcall_preservation_test.go b/internal/lua/toolcall_preservation_test.go index ed56ea5..c41cfbb 100644 --- a/internal/lua/toolcall_preservation_test.go +++ b/internal/lua/toolcall_preservation_test.go @@ -36,8 +36,8 @@ func TestAdaptersPreserveToolCalls(t *testing.T) { ) // Adapters that identify a tool result by the call id. - byID := []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral", - "sensenova", "trae", "anthropic"} + byID := []string{"openai", "deepseek", "github", "groq", "mistral", + "sensenova", "anthropic"} for _, a := range byID { out, err := vm.Transform(a, "transform_request", body) diff --git a/internal/lua/usage_plugin_test.go b/internal/lua/usage_plugin_test.go index e52819c..4227529 100644 --- a/internal/lua/usage_plugin_test.go +++ b/internal/lua/usage_plugin_test.go @@ -12,8 +12,7 @@ import ( // 1. SHIPPED. `usage` is the reason /v1/usage exists at all, and it only exists // for deployments that receive it. A gateway build that does not embed it // answers 404 and the frontend shows "后台未开放消费接口" — which reads as a -// bug rather than as a missing optional feature. Same reasoning billing.lua and -// quota.lua are embedded. +// bug rather than as a missing optional feature. Same reasoning billing.lua. // // 2. SELF-SCOPED. The handler reports the CALLER's usage, so it must look the // caller up by the key in the request, never by a key from its own @@ -110,3 +109,14 @@ func TestUsagePeriodIsAll(t *testing.T) { t.Error(`usage.lua no longer defaults to period "all"`) } } + +// readShippedPlugin reads an optional plugin from the repository (not the +// binary: plugins are never embedded). Serves tests that assert on Lua source. +func readShippedPlugin(t *testing.T, name string) string { + t.Helper() + src, err := ReadBundledPlugin(strings.TrimSuffix(name, ".lua")) + if err != nil { + t.Fatalf("read repo plugin %s: %v", name, err) + } + return src +} diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index 8234c12..5c708f1 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -32,7 +32,7 @@ func TestLoadBundledAdapters(t *testing.T) { for _, a := range adapters { names[a.Name] = true } - for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode"} { + for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama"} { if !names[want] { t.Errorf("missing adapter %s (got %v)", want, names) } @@ -115,36 +115,6 @@ func TestBuildHeadersFallbackStatic(t *testing.T) { } } -func TestBuildHeadersCustomHook(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - hdrs, err := vm.BuildHeaders("kimicode", map[string]interface{}{ - "timestamp": int64(12345), - "api_key": "k", - "body": "{}", - "method": "POST", - "url": "http://x/chat", - "source": map[string]interface{}{"meta": map[string]interface{}{ - "app_id": "app-9", "app_secret": "s", "api_key": "k", - }}, - }) - if err != nil { - t.Fatalf("build headers: %v", err) - } - if hdrs["X-App-Id"] != "app-9" { - t.Fatalf("x-app-id = %q", hdrs["X-App-Id"]) - } - if hdrs["X-App-Sign"] == "" { - t.Fatal("expected signature header") - } - if hdrs["X-Timestamp"] != "12345" { - t.Fatalf("timestamp = %q", hdrs["X-Timestamp"]) - } -} - func TestDisableThinkingPassthrough(t *testing.T) { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil { @@ -296,7 +266,7 @@ func TestAdaptersPassFinishReason(t *testing.T) { // every chunk) must NOT terminate the stream. chunk := `{"choices":[{"index":0,"finish_reason":"tool_calls","delta":{"content":""}}]}` empty := `{"choices":[{"index":0,"finish_reason":"","delta":{"content":"x"}}]}` - for _, name := range []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral"} { + for _, name := range []string{"openai", "deepseek", "github", "groq", "mistral"} { out, err := vm.Transform(name, "transform_stream_chunk", chunk) if err != nil { t.Fatalf("%s: %v", name, err) @@ -521,7 +491,7 @@ func TestAdaptersReportZeroCacheHit(t *testing.T) { defer vm.Stop() // OpenAI-shaped upstreams: usage.prompt_tokens_details.cached_tokens = 0 - openaiLike := []string{"openai", "deepseek", "sensenova", "github", "groq", "kimicode", "mistral"} + openaiLike := []string{"openai", "deepseek", "sensenova", "github", "groq", "mistral"} respBody := `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}], "usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12, "prompt_tokens_details":{"cached_tokens":0}}}` @@ -1248,8 +1218,8 @@ func TestNonStreamToolCallsPreserved(t *testing.T) { `"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}` for _, name := range []string{ - "openai", "sensenova", "trae", "deepseek", "github", - "groq", "kimicode", "mistral", + "openai", "sensenova", "deepseek", "github", + "groq", "mistral", } { out, err := vm.Transform(name, "transform_response", body) if err != nil { @@ -1369,86 +1339,6 @@ func TestOllamaNonStreamToolCalls(t *testing.T) { } } -// TestTraeTextToolCallRecovery covers the trae-specific failure: its upstream -// (trae-local-api's OpenAI endpoint) never reads the request's `tools` array, so -// the relayed model prints a block as TEXT, leaves -// message.tool_calls null and reports finish_reason:"stop". An OpenAI client -// then sees an ordinary completion and its agent loop ends mid-conversation. -// The adapter must recover the structured call, strip the block from content, -// and correct finish_reason. -func TestTraeTextToolCallRecovery(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - - cases := []struct { - name string - body string - }{ - { - "tool_call tag with arguments", - `{"choices":[{"message":{"role":"assistant","content":"\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Beijing\"}}\n"},"finish_reason":"stop"}]}`, - }, - { - // the same codebase's Anthropic endpoint prompts for and "params" - "toolcall tag with params", - `{"choices":[{"message":{"role":"assistant","content":"{\"name\": \"get_weather\", \"params\": {\"city\": \"Beijing\"}}"},"finish_reason":"stop"}]}`, - }, - { - "prose around the block is preserved", - `{"choices":[{"message":{"role":"assistant","content":"Let me check.\n{\"name\":\"get_weather\",\"arguments\":{\"city\":\"Beijing\"}}"},"finish_reason":"stop"}]}`, - }, - } - - for _, c := range cases { - out, err := vm.Transform("trae", "transform_response", c.body) - if err != nil { - t.Fatalf("%s: transform: %v", c.name, err) - } - var got struct { - Content string `json:"content"` - FinishReason string `json:"finish_reason"` - ToolCalls []struct { - Name string `json:"name"` - Arguments map[string]interface{} `json:"arguments"` - } `json:"tool_calls"` - } - if err := json.Unmarshal([]byte(out), &got); err != nil { - t.Fatalf("%s: unmarshal %v (%s)", c.name, err, out) - } - if len(got.ToolCalls) != 1 { - t.Fatalf("%s: text tool call not recovered: %s", c.name, out) - } - if got.ToolCalls[0].Name != "get_weather" { - t.Errorf("%s: name wrong: %s", c.name, out) - } - if got.ToolCalls[0].Arguments["city"] != "Beijing" { - t.Errorf("%s: args wrong: %s", c.name, out) - } - if got.FinishReason != "tool_calls" { - t.Errorf("%s: finish_reason must be corrected to tool_calls: %s", c.name, out) - } - if strings.Contains(got.Content, " Recommends: ca-certificates -Homepage: https://gitcode.com/JianFeeeee/ModelRouter +Homepage: https://atomgit.com/HeNanMU-EEA/ModelRouter Description: Unified OpenAI-compatible multi-source LLM gateway. Routes requests across multiple upstream LLM providers with Lua adapters, model scheduling, quota windows, an elastic Lua worker pool, and a Web UI. @@ -88,7 +88,7 @@ Version: $VERSION Release: 1 Summary: Unified OpenAI-compatible multi-source LLM gateway License: MIT -URL: https://gitcode.com/JianFeeeee/ModelRouter +URL: https://atomgit.com/HeNanMU-EEA/ModelRouter BuildArch: x86_64 Requires: ca-certificates AutoReqProv: no diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 6953004..b131fe5 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -15,7 +15,7 @@ description: | Routes requests across multiple upstream LLM providers with Lua adapters, model scheduling, quota windows, an elastic Lua worker pool, and a Web UI. vendor: ModelRouter -homepage: https://gitcode.com/JianFeeeee/ModelRouter +homepage: https://atomgit.com/HeNanMU-EEA/ModelRouter license: MIT contents: # binary diff --git a/packaging/upload-release.sh b/packaging/upload-release.sh index 413b97b..ce3157a 100755 --- a/packaging/upload-release.sh +++ b/packaging/upload-release.sh @@ -24,7 +24,9 @@ REPO="HeNanMU-EEA/ModelRouter" TAG="${1:?usage: upload-release.sh ...}" shift -API="https://gitcode.com/api/v5/repos/${REPO}" +# 仓库托管在 AtomGit(与 gitcode 同一后端)。API 基址可覆盖,便于换托管或 +# 用私有部署;默认值保持合并前的 v5 路径即可工作。 +API="${UPLOAD_API_BASE:-https://atomgit.com/api/v5}/repos/${REPO}" for f in "$@"; do name="$(basename "$f")"