查证参考仓架构后的结论: - 参考:一个平台一个继承宿主 LlmAdapter 的真类(trae 做 SOLO 转换、 codearts 做 AK/SK 签名、zcode 走 Anthropic),公共逻辑抽在 openai-compat.ts(1196 行,8/12 adapter 复用) - 我们:内核是 proxy,插件只注册源;平台差异被当成 adapter 名差异 - 关键:内核**早已实现** __llmsproxy_adapters.install(含归属校验、冲突 仲裁、测试 TestPluginInstallsAdapterFromLua 通过),但 channels.lua 对这个全局的引用数是 **0** —— 一个适配器都没装过 - 我们还缺参考的共享前导:openai.lua 与 anthropic.lua 各复制一份 safe_tool_id - 记录了下一步落地前必须先探的两条风险(适配器 VM 能否读 secrets、 流式转换能否跨 chunk 保持状态)
13 KiB
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:
- 起本地回调服务器,端口须 ≥10000(
MIN_CALLBACK_PORT,低于此 portal 拒绝), 路径/oauth/callback - 打开
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(会被判异常并回退) - 回调收到
code后,带 DPoP 证明向https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens换credentials{access_key_id, secret_access_key, security_token, expiration}refresh_token
- 凭据持久化须含续期材料:
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 还有三件必须保留的细节:
- 身份五头逐字写死、不许随机(且不带
x-goog-api-key/x-goog-api-client) - 流式请求刻意不带
Accept头 - 信封逐层字母序序列化(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 | 同上 |
五、本机实测踩到的坑(都已修)
xxd不存在 ——command -v xxd为空,用它取十六进制会静默得到空串。 改用od -An -tx1。openssl ec -text会截断 32 字节 priv(实测只给 30 字节)→d必须用openssl asn1parse的[HEX DUMP];tail -c 32拿到的是 SEC1 结构里 公钥的 y,不是d。- Lua local 前向引用:
local function只从定义处向后可见。hexToBin/b64url/b64urlRaw被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块 之前(本文件累计踩过 6 次)。 min_port参数读到argv[2]与out_path冲突 —— 回调脚本的第 5 个 参数才是 minPort。- 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 处引用)。
所以正确做法是两层:
- 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类), 或内核在加载时注入 —— 消掉复制粘贴
- channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、
codearts AK/SK 签名)装自己的适配器,而不是硬塞给
openai
所需的适配器能力(crypto + 出站 + 流式)
已核实内核提供:
sha256_hex/hmac_sha256_hex(vm.gosetupGlobals)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)
这两条是下一步落地前必须先探的。