Files
ModelRouter/docs/channels-parity.md
JianFeeeee f55d514e85 docs(channels): 生产实测 —— 不可用的真相是 13 个账号里只有 1 个有凭据
用户报「完全不可用」。逐平台实测 account/list 后根因不是代码:

| 平台 | 账号 | 有凭据 |
|---|---|---|
| buddy | 2 | 1 |
| trae/minimax/lobsterai/codearts | 3/1/1/5 | 0 |
| 其余 7 平台 | 0 | 0 |

13 个账号只有 1 个真有凭据,其余是**占位壳子**(has_credential:false)。
此前把「12 平台 0 个 manual」当作已接好的判据是**错的** —— manual 是 flow
类型,不等于登录成功。

排除项(全部实测,非推断):
- 部署一致性:4 插件 sha256 与仓库全部一致,关键标记全在位
- **推理链路可用**:POST /v1/chat/completions 实测返回 "OK"
- 登录机制可用:minimax 真实返回 user_code,trae 起了服务端浏览器
- 设备码轮询正确:未授权时持续 pending,不误判成功/失败

结论:剩 12 平台都需**人工授权**(device-poll 去官方页输码、callback 在
VNC 里点、qr/wechat 扫码)。

B 类修正判断:
- qoder-wasm:二进制不在仓库(find -name '*.wasm' 为空),拿到才有意义
- zcode-captcha:**登录不需要它**。captcha 只用于签到/领取
  (zcode-auth.ts:22-23 每个 plan 单独 mint)—— 真实价值是让领取全自动,
  不是让平台可用
2026-10-14 00:28:32 +08:00

20 KiB
Raw Blame History

channels 插件与参考插件的三线对齐表

参考仓:Ebony-Vinyl/dsh-our-free-model(本地克隆 /tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model),关键实现位于 vendor/channel-pack/src/(144 个 TS 文件)。

命名规律(照它逐厂商对齐的三条线):

后缀 职责 我们要对齐的东西
X-auth.ts / X-oauth.ts / login.ts 登录 登录流程、端点、参数、凭据形状
X-product.ts / models.ts 模型获取 远端端点、解析路径、兜底表
X-adapter.ts / llm-adapter.ts 接入模型服务 chat 端点、载荷转换、鉴权头

一、登录(line ①)

平台 参考流程 我们的 flow 状态
trae OAuth 17 参数 + 设备身份派生 callback ✅ 逐字节对齐(含 login_trace_id / machine_id / x_device_*)
minimax device-poll device-poll ✅
cline device-poll device-poll ✅
buddy state-poll(无回调) state-poll ✅
gemini OAuth + client_secret callback ✅(含 client_secret、localhost 主机、6 scope)
lobsterai 本地回调 + authCode 换 token callback ✅(含动态版本号、3 步活动领取)
opencode paste paste ✅(参考本身即粘贴凭据)
codearts PKCE + DPoP ES256 + STS 换 AK/SK callback ✅ 本轮修复(此前误标 paste)
qoder device-poll + 设备身份 device-poll ✅(含 Cosy-MachineToken/Type 成对)
zcode init-poll init-poll ✅
loomy 微信扫码 poll wechat-poll ✅
raccoon 二维码 poll qr-poll ✅

codearts 登录(本轮修复的重点)

此前标成 flow="paste"(手填凭据)是错的。参考 login.ts / oauth.ts 默认走新式 IAM OAuth:

  1. 起本地回调服务器,端口须 ≥10000(MIN_CALLBACK_PORT,低于此 portal 拒绝), 路径 /oauth/callback
  2. 打开 https://codearts.huaweicloud.com/portal/authorize,参数逐项: theme=2、locale=zh-cn、uri_scheme=codearts-agent、client_id=codearts-agent、 code_challenge_method=**SHA-256**(不是 RFC 的 S256 —— 错值会让 portal 回退旧 ticket 流程,看起来"也能用"但不是 OAuth)、ticket_id、 plugin-name=snap_AIIDE、plugin-version=5.2.0; 不得含 auth_callback_url(会被判异常并回退)
  3. 回调收到 code 后,带 DPoP 证明向 https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens 换 credentials{access_key_id, secret_access_key, security_token, expiration}
    • refresh_token
  4. 凭据持久化须含续期材料:refresh_token / code_verifier / dpop_private_key_jwk

DPoP 细节:ES256 JWS,header={alg,typ:dpop+jwt,jwk}、 payload={htm,htu,iat,jti};签名是 raw r||s 64 字节,而 openssl 输出 DER ——必须转换格式,否则服务端验签失败而字符串看起来完全正常。

终态判定只认 invalid_grant / ErrorCode 含 ExpiredRefreshToken; 不认 InvalidDPoPHeader(那是一次请求层面的拒绝:时钟偏差、proof 重放、 网关抖动都会触发,当终态会把完好账号一步标死)。

旧 ticket 流程(CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket 轮询) 保留作回退。

二、模型获取(line ②)

远端优先、兜底补位的机制已具备(models_remote + effectiveModels)。 本轮把兜底表逐平台补成参考全量:

平台 参考来源 兜底数 状态
trae TRAE_FALLBACK_MODELS 28 可见 + 4 hidden ✅(hidden 单列 models_hidden)
minimax GET /mavis/api/v1/models?region=cn&buildEnv=prod 4 ✅
cline GET /api/v1/models + recommended-models 3 ✅
buddy (无独立表) 7 ✅
gemini — 2 ✅
lobsterai GET /api/models/available(须带 model_flag) 19 ✅
opencode GET /v1/models 14 ✅(含 models_free)
codearts opengw gateway/config + /v1/model/builtin 3 ⚠️ 见下
qoder /api/v2/model/list(需 WASM 签名) 19 ✅(含 CN 独有 q37fmodel/gm51model)
zcode GET /api/v1/client/configs 2 ✅
loomy GET /api/v1/models(取 type==='chat') 8 ✅
raccoon 远端 + 兜底 6 ✅

trae 的 isHidden 语义:browser_use_subagent / explore_sub_agent_v13 / explore_sub_agent_v2 / summary 是内部子 agent,不进用户可选列表。 model_list 已按此过滤(models_hidden)。

opencode 的 isFree:参考明确标注 7 个模型匿名可用、其余需付费 key。 此前 model_list 把 isFree 硬编码为 false,等于把官方免费模型标成付费。

codearts 模型获取(未闭环)

参考 models.ts 从两个端点拉:

  • OPENGW_GATEWAY_CONFIG_URL(opengw.developer.huaweicloud.com/api/v1/gateway/config) → result.models,这是 benefit(免费额度)模型权威来源
  • SNAP_MODEL_BUILTIN_URL(/v1/model/builtin)→ builtinModels,常规模型

两者都要 AK/SK 的 SDK-HMAC-SHA256 签名。另有 normalizeModelId (去尾部 -NNNN 日期版本)与 isCodeArtsBenefitModel(决定 chat 是否带 maas_type: benefit,不带则 404 model is not registered)。 这部分尚未实现——我们的 codearts 目前只有 3 个硬编码模型名。

三、接入模型服务(line ③)

本轮发现的最严重问题:源注册时 base_url + adapter.endpoint 拼出的 chat URL 全部缺路径前缀,11 个平台里 11 个都不对。已逐个修正:

平台 正确 chat 端点 修正前
trae …/api/agent/v3/llm_utils_chat …/chat/completions
cline api.cline.bot/api/v1/chat/completions api.cline.bot/chat/completions
lobsterai …/api/proxy/v1/chat/completions …/chat/completions
qoder api2-v2.qoder.sh/model/v1/chat/completions …/chat/completions
buddy copilot.tencent.com/v2/chat/completions …/chat/completions
codearts …/api/v2/chat/completions …/chat/completions
opencode opencode.ai/zen/v1/chat/completions opencode.ai/zen/chat/completions
raccoon …/api/web/llm/v2/chat/completions …/chat/completions
loomy loomy.xunfei.cn/chat/completions loomy.xunfei.cn
zcode zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages zcode.z.ai/v1/messages
gemini /v1internal:generateContent(私有动词) /v1beta/models/{model}:generateContent(公开,凭据不匹配)

✅ 已闭环:三个私有协议平台改用插件自装适配器

这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主 LlmAdapter 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。 内核其实早已实现 __llmsproxy_adapters.install(含归属校验/冲突仲裁/ 测试),但 channels 插件对该全局的引用数是 0。现已第一次真正使用:

平台 自装适配器 解决的协议差异
codearts codearts 华为云 SDK-HMAC-SHA256 AK/SK 签名(canonical/string-to-sign 逐字节)
trae channels-trae SOLO 载荷:config_name 双字段、function 通道、tool_calls→function_call、parameters→JSON 字符串、tool_choice 归一化
gemini channels-gemini 私有 /v1internal: 信封 + 身份五头 + 字母序序列化 + 响应剥一层

命名说明:trae 用 channels-trae 而非 trae,因为内置 trae.lua 服务的是 运维自建的 trae-local-api 反代(127.0.0.1:19900,生产 config.yaml 在用), 与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)—— 判据里专门断言内置 trae 未被顶掉。

新增能力:credential_shape。签名平台需要完整凭据 JSON(适配器自己 解 AK/SK),其余平台交裸 access token。此前 syncAccountSource 一律用 credentialOf 取 access_token,于是 codearts 凭据(只有 access_key_id/secret_access_key/security_token)取不到 token → 静默 return、不出源。现已按 plat.credential_shape 区分。

另一处静默失败已修:syncAccountSource 拿到内核 add_source 的失败 (权限未授、形状不符)时不记日志,表现为"账号加了但不出源",排查零线索。 现补失败日志(区分内核 err 与 json.ok==false)。

仍未闭环:gemini 的 sigstore(明确不做)

参考 gemini-sigstore.ts(268 行)持久化 tool-call 签名,供跨请求回填。 我们推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒, 适配器如实透传错误。理由与结论已记在此处,避免反复讨论。

四、明确不做的(附理由)

参考模块 行数 为什么不做
gemini-sigstore.ts 268 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔
qoder-wasm.ts 1442 需随插件分发 298KB wasm 二进制;B 类,待立项
zcode-captcha.ts 2212 阿里云滑块,需真实浏览器/验证码链路;B 类
captcha-*.ts 1223 同上

五、本机实测踩到的坑(都已修)

  1. xxd 不存在 —— command -v xxd 为空,用它取十六进制会静默得到空串。 改用 od -An -tx1。
  2. openssl ec -text 会截断 32 字节 priv(实测只给 30 字节)→ d 必须用 openssl asn1parse 的 [HEX DUMP];tail -c 32 拿到的是 SEC1 结构里 公钥的 y,不是 d。
  3. Lua local 前向引用:local function 只从定义处向后可见。hexToBin / b64url / b64urlRaw 被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块 之前(本文件累计踩过 6 次)。
  4. min_port 参数读到 argv[2] 与 out_path 冲突 —— 回调脚本的第 5 个 参数才是 minPort。
  5. Lua 双引号串里的 \1 是转义坑(sed 's/…/…/\1/' 会报 invalid escape sequence)→ 改用 awk -F': ' '{print $NF}'。

为什么参考插件是"正常"的:架构差异(2026-10-13 查证)

用户问「为什么他们就正常」。查完参考仓的架构后,答案是三层结构差异, 不是某一个端点的笔误。

参考的架构:一个平台 = 一个适配器类

参考依赖宿主 SDK 的 seam:import { LlmAdapter } from '@deepseek-ai/dsh-llm'。 每个平台有一个继承基类的真类,同时拥有登录、模型目录、推理三件事:

文件 类 干什么
trae-adapter.ts TraeAdapter SOLO 协议转换(config_name/function,非 OpenAI 兼容)
llm-adapter.ts CodeArtsAdapter 华为云 AK/SK 签名 + maas_type: benefit 判定
zcode-adapter.ts ZcodeAdapter Anthropic Messages 协议(非 OpenAI)
qoder-adapter.ts QoderAdapter WASM 加密端点 + 信封剥离
gemini-adapter.ts GeminiAdapter /v1internal: 私有动词 + 字母序信封

注册方式是 registerAdapterIdempotent(ctx.llm, [PROVIDER], adapter) (llm-adapter.ts:2080)。

但它不重复:共享层是 src/openai-compat.ts(1196 行)—— serializeMessages(消息序列化)、consumeOpenAiSse(SSE 消费)、 httpErrorCode / isTransportError(错误归类)、userContentParts(多模态)。 12 个 adapter 里有 8 个 import from './openai-compat'。

即:公共协议逻辑一份,平台差异各自一个类。

我们的架构:内核是 proxy,插件只注册源

  • 适配器是 Lua 文件(12 个),通过 4 个钩子参与: transform_request / transform_response / transform_stream_chunk / transform_error,外加 build_headers(动态签名,见下)。
  • 声明面:endpoint / endpoint_stream / ingress / headers。
  • 插件通过 __llmsproxy_kernel.call("add_source", {source={...}}) 把平台 注册为上游源,source.adapter 引用适配器名。

关键发现:机制早就有,但 channels 插件从没用过

内核完整实现了插件自装适配器:

internal/lua/plugin_adapter_bridge.go
  func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error)
  registerAdapterBridge → Lua 全局表 __llmsproxy_adapters.install / .update / .remove
  • 有归属校验(adapterOwnership:只能改自己装的)
  • 有冲突仲裁(影子内置/他人所有 → 挂起等运维裁决,不是硬拒)
  • 有测试证明可用:TestPluginInstallsAdapterFromLua(PASS)

但 grep -c '__llmsproxy_adapters' internal/lua/plugins/channels.lua = 0。 channels 插件一个适配器都没装过 —— 它只会注册 adapter = "openai" 的源。 这就是"为什么我们不正常"的直接原因:平台协议差异被当成适配器名差异。

我们还缺的一层:共享协议模块

参考有 openai-compat.ts;我们没有对应物。openai.lua 自己写明 「Lua adapters have no shared prelude」,safe_tool_id 在 openai.lua 与 anthropic.lua 里各复制了一份(各 6 处引用)。

所以正确做法是两层:

  1. 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类), 或内核在加载时注入 —— 消掉复制粘贴
  2. channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、 codearts AK/SK 签名)装自己的适配器,而不是硬塞给 openai

所需的适配器能力(crypto + 出站 + 流式)

已核实内核提供:

  • sha256_hex / hmac_sha256_hex(vm.go setupGlobals)
  • build_headers(meta) 收 {url, method, body, api_key, timestamp, client_session, source{name,meta}} —— CodeArts 的 AK/SK 签名用它 (与参考 signRequestHuawei 同构,但 AK/SK 要从 api_key 里解出)
  • transform_stream_chunk 逐 chunk 调用(provider.go:1295)
  • 插件有完整 OS 能力(io.popen/os.execute)→ 可用 openssl 做 EC 签名

未验证的风险

  • 适配器 VM 能否访问 __llmsproxy_secrets(AK/SK 要解出 SK 才能签名)
  • 流式转换能否跨 chunk 保持状态(trae 的 SSE 需要跨帧累积 tool_calls)

这两条是下一步落地前必须先探的。


插件自装适配器:机制与判据(2026-10-13)

机制(内核早已具备,本轮才开始使用)

internal/lua/plugin_adapter_bridge.go
  InstallAdapter(plugin, name, code)   # 含归属校验、冲突仲裁(影子内置 → 挂起等裁决)
  registerAdapterBridge → Lua 全局 __llmsproxy_adapters.install / .update / .remove

注册点在 bootPluginState,先于 DoString(code) —— 所以插件在脚本执行期 就能装适配器。适配器装在适配器 VM(与插件 VM 分离,各自独立)。

三条能力(实测,不是推断)

internal/lua/plugin_adapter_capability_test.go 锁死:

能力 结论 为什么关键
拿凭据 适配器能经 build_headers 的 meta.api_key 拿到(url/method/body 也在) AK/SK 签名的前提;同时验不到 __llmsproxy_secrets(只注入插件 VM,安全边界完好)
流式状态 transform_stream_chunk 跨 chunk 保持同一 Lua 状态(表地址恒定) SOLO 跨帧累积 tool_calls、gemini 跨帧剥信封的前提
加密原语 适配器可用内核 sha256_hex / hmac_sha256_hex(用标准向量校验) 签名不需要外部进程

踩过的坑(都是"静默失败"类,值得记)

  1. function 是 Lua 关键字 —— 写 body.function = x 是语法错误 ('<name>' expected near 'function'),必须 body["function"]。 插件脚本本身能编译通过,只在适配器 load() 时才暴露。
  2. Lua 长字符串里不能有字面 NUL 字节 —— 报 unfinished long string near '<eof>',且报在文件末尾,完全指不到现场。
  3. 本 Lua 构建不支持按位异或 ~(unexpected symbol near '~')。
  4. 装载失败只回"源码无法编译"、不给行号 —— 三个自装适配器现统一加 本地预编译护栏(先 load 一次拿行号)。上面三个坑都是靠它定位的。
  5. 抽公共代码要抽最全的实现。前导重构时我先抽了 transform_error 的 最小版(只认 {error:{message}}),打挂 6 个既有测试 —— 最小版丢掉了 HTML 错误页、裸文本、扁平 {code,message}、FastAPI {detail}。 抽最小实现是静默降级:不报错,只是少认几种错误体。
  6. 判据必须能看见真实函数的返回值。我一度用 kernelTestCore 裸加载插件 (没授 add_source 权限)测源注册,源一直不出;直到把内核原始返回 (kernel action add_source is not granted to plugin channels)带出来才 定位。黑盒断言只在失败时告诉你"不对",不告诉你"为什么"。

判据清单

文件 锁死什么
internal/lua/plugin_adapter_capability_test.go 三条能力(凭据/流式状态/加密原语)
internal/lua/adapter_prelude_test.go 前导注入 + 语义值正确 + 旧适配器行为不变
internal/lua/adapter_prelude_equivalence_test.go 重构前后逐字节等价(含反向断言)
internal/lua/channels_trae_solo_test.go SOLO 7 条规则 + 三处反向断言
internal/lua/channels_gemini_adapter_test.go 身份五头逐值 + 不得发 google key/Accept + 字母序 + 响应剥信封
internal/core/channels_codearts_adapter_test.go 装机/归属 + Go stdlib 独立复算 HMAC 验签
internal/core/channels_private_adapter_wiring_test.go 三平台的源真的引用了自装适配器

生产实测:不可用的真相(2026-10-14)

症状与根因

用户报"完全不可用"。实测(/api/plugins/http/channels/account/list 逐平台):

平台 账号数 有凭据的
buddy 2 1
trae 3 0
minimax 1 0
lobsterai 1 0
codearts 5 0
其余 7 平台 0 0

13 个账号里只有 1 个真有凭据,其余全是占位壳子(has_credential: false)。 占位账号不是"已登录",它只是为「先建账号行、登录后填凭据」这个两步流程预留的 行。此前的判断只看「12 平台 0 个 manual」,那是错的判据 —— manual 是 flow 类型,不是"登录成功"。

排除项(都实测过,不是猜)

检查 结果
服务状态 active,NRestarts=0
部署一致性 4 个插件 sha256 与仓库全部一致;关键标记(installCodeartsAdapter / installTraeAdapter / installGeminiAdapter / sorted_json / sign_parts)全部在位
推理链路 POST /v1/chat/completions 实测返回 "OK"(走 buddy 源、deepseek-v4.1-flash)—— 网关本身完全可用
登录机制 minimax 真实返回 user_code=T49S-RC7G + 验证地址;trae 起了服务端浏览器 + 回环回调
设备码轮询 5 次全部 pending(未授权时不误判为失败或成功 —— 这是正确行为)

结论:不是代码坏了,是授权没完成

剩下的 12 个平台都需要人工动作才能走完登录:

  • device-poll(minimax / cline / qoder):把 user_code 拿到 account.minimax.cn/oauth-authorize 之类的官方页面输入
  • callback(trae / gemini / lobsterai):在 VNC 窗口里点授权 (回环回调只能由本机解析,远程浏览器永远接不住)
  • qr-poll / wechat-poll(raccoon / loomy):扫码
  • init-poll(zcode):授权页 + 可能的验证码

这些按设计就必须人工,assistant 无法独立端到端完成。

B 类:实测结论

项 行数 结论
qoder-wasm + envelope 706 + 226 二进制不在仓库(find -name '*.wasm' 为空,属构建产物)。没有 qoder-auth-wasm.wasm 无法实现;拿到才有意义
zcode-captcha + carrier 2119 + 93 + 280 + 206 阿里云滑块。且登录本身不需要它 —— captcha 只用于签到/领取环节(zcode-auth.ts:22-23:claim 时每个 plan 单独 mint captcha)。登录闭环不受阻,受阻的是自动领取
captcha-pool 280 同上,服务于领取环节的验证码供给

修正判断:B 类不是"登录闭环的拦路虎"。zcode 的登录(/api/v1/oauth/cli/init → poll)不需要验证码;captcha 只在自动领取时出现。所以 B 类的真实价值是 "让领取全自动",而不是"让平台可用"。