Files
ModelRouter/docs/channels-parity.md
JianFeeeee faa3be5c37 test+docs(channels): 三私有协议平台的源接线判据 + 自装适配器机制实录
补齐"装了适配器 ≠ 源用了它"这条判据。这是这类改动最可能的漏配,且
**不会报错** —— 只是签名/信封不生效、上游返回流内错误或 401,看起来像
"平台不稳定"。三个平台的失败模式不同、根因同一个(平台协议差异被当成
适配器名差异):
  codearts → 华为云 AK/SK 签名
  trae     → SOLO 载荷格式
  gemini   → 私有 /v1internal: 信封 + 身份五头
判据同时验 adapter 名、base_url 与"源里必须有模型"。

docs/channels-parity.md 补两节:
- 三个私有协议平台已改用插件自装适配器(含 credential_shape 的引入原因:
  codearts 凭据没有 access_token,credentialOf 取不到 → 静默不出源)
- 附录:机制(内核早已具备,channels 插件引用数为 0)、三条实测能力、
  六个"静默失败"类踩坑、判据清单
2026-10-13 23:44:30 +08:00

318 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` | 三平台的**源真的引用了**自装适配器 |