diff --git a/docs/channels-parity.md b/docs/channels-parity.md index c717c94..1a319be 100644 --- a/docs/channels-parity.md +++ b/docs/channels-parity.md @@ -117,31 +117,45 @@ chat URL **全部缺路径前缀**,11 个平台里 11 个都不对。已逐个 | 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`(查远端模型列表)。 +这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主 +`LlmAdapter` 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。 +内核其实**早已实现** `__llmsproxy_adapters.install`(含归属校验/冲突仲裁/ +测试),但 channels 插件对该全局的引用数是 **0**。现已第一次真正使用: -我们有一个 `internal/lua/adapters/trae.lua`,但它描述的是 -`trae-local-api`(本地 OpenAI 兼容代理,`127.0.0.1:19900`),**不做** SOLO -转换(`grep config_name` = 0 命中),且平台声明用的是 `adapter = "openai"`。 -所以 trae 的**端点现在对了,载荷仍然错**。这是一块独立性工作。 +| 平台 | 自装适配器 | 解决的协议差异 | +|---|---|---| +| 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:` **信封** + 身份五头 + 字母序序列化 + 响应剥一层 | -### 未闭环:gemini 的私有协议信封 +命名说明:trae 用 `channels-trae` 而非 `trae`,因为内置 `trae.lua` 服务的是 +**运维自建的 trae-local-api 反代**(`127.0.0.1:19900`,生产 config.yaml 在用), +与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)—— +判据里专门断言内置 `trae` 未被顶掉。 -端点是 `/v1internal:` 了,但参考 `gemini.ts` 还有三件必须保留的细节: -1. **身份五头逐字写死、不许随机**(且**不带** `x-goog-api-key` / `x-goog-api-client`) -2. **流式请求刻意不带 `Accept` 头** -3. **信封逐层字母序序列化**(Go 的 `encoding/json` 对 map 自动字母序,TS 的 - `JSON.stringify` 按插入序 —— 不排序字节就与上游不同) +**新增能力:`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 | 我们推理走无状态适配器,插件不参与推理 → 插件侧缓存会成为死代码 ⛔ | +| `gemini-sigstore.ts` | 268 | 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔ | | `qoder-wasm.ts` | 1442 | 需随插件分发 298KB wasm 二进制;B 类,待立项 | | `zcode-captcha.ts` | 2212 | 阿里云滑块,需真实浏览器/验证码链路;B 类 | | `captcha-*.ts` | 1223 | 同上 | @@ -246,3 +260,58 @@ channels 插件一个适配器都没装过 —— 它只会注册 `adapter = "op - 流式转换能否跨 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` | 三平台的**源真的引用了**自装适配器 | diff --git a/internal/core/channels_private_adapter_wiring_test.go b/internal/core/channels_private_adapter_wiring_test.go new file mode 100644 index 0000000..a079fc2 --- /dev/null +++ b/internal/core/channels_private_adapter_wiring_test.go @@ -0,0 +1,86 @@ +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) + } + aid, _ := j["account_id"].(string) + + srcs := listSourcesViaKernel(t, c) + sm, ok := srcHasAccount(srcs, aid) + 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"]) + } + }) + } +}