# 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`(公开,凭据不匹配) | ### 未闭环:trae 的载荷转换 trae **不是 OpenAI 兼容**。参考 `trae.ts:30` 注释: 「载荷必须转换:OpenAI 的 `{model, messages, tools, tool_choice, stream}` 需要 映射为 SOLO 格式(`function`、`config_name` 等字段),不能透传」; `model` 还要映射到 `config_name`(查远端模型列表)。 我们有一个 `internal/lua/adapters/trae.lua`,但它描述的是 `trae-local-api`(本地 OpenAI 兼容代理,`127.0.0.1:19900`),**不做** SOLO 转换(`grep config_name` = 0 命中),且平台声明用的是 `adapter = "openai"`。 所以 trae 的**端点现在对了,载荷仍然错**。这是一块独立性工作。 ### 未闭环:gemini 的私有协议信封 端点是 `/v1internal:` 了,但参考 `gemini.ts` 还有三件必须保留的细节: 1. **身份五头逐字写死、不许随机**(且**不带** `x-goog-api-key` / `x-goog-api-client`) 2. **流式请求刻意不带 `Accept` 头** 3. **信封逐层字母序序列化**(Go 的 `encoding/json` 对 map 自动字母序,TS 的 `JSON.stringify` 按插入序 —— 不排序字节就与上游不同) ## 四、明确不做的(附理由) | 参考模块 | 行数 | 为什么不做 | |---|---|---| | `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) 这两条是下一步落地前必须先探的。