diff --git a/docs/channel-repair-plan.md b/docs/channel-repair-plan.md new file mode 100644 index 0000000..a174aa1 --- /dev/null +++ b/docs/channel-repair-plan.md @@ -0,0 +1,183 @@ +# 渠道链路修复计划与现状 + +> 本文是 `plan.md` 中「渠道链路修复计划(2026-10-17 起)」章节的**可提交版本**。 +> `plan.md` 本身被 `.gitignore` 忽略(本地工作台账),故关键计划与现状单独落在这里, +> 以便随代码一起进 git、供他人查阅。 +> +> **写作原则:只写实测到的,标注"推断"的必须写明。** 每条缺陷都附可复现的观测。 + +> 记录 **2026-10-17 用户报障三连修**(模型不可用 / 额度异常 / 其他渠道同类) +> 的现状与后续计划。上接 `plan.md` §9(v2 契约收尾)。 + +## 1. 现状快照(2026-10-17 14:00 实测,非推断) + +### 1.1 生产实例 + +| 项 | 值 | +|---|---| +| 服务 | `active`,NRestarts=0 | +| 二进制 sha256 | `56396f79bfc2433a`(与仓库逐字节一致) | +| 插件 sha256 | `c15cb4bf5d5c0f21`(与仓库逐字节一致) | +| 插件 | 4 个(billing/channels/quota/usage)**全部 loaded** | +| 源 / 适配器 | 21 源 / 23 适配器 | +| 账号池 | **仅 trae、buddy 各有 1 个有凭据账号**;其余 10 个平台**账号数 0** | + +### 1.2 各渠道缺陷矩阵(本文件写入时的实测) + +自装适配器(插件在加载时经 `__llmsproxy_adapters` 装入): + +| 适配器 | `build_headers` | 静态 `adapter.headers` | 流式转换 | 鉴权是否可能错 | +|---|---|---|---|---| +| `codearts` | 有 | 无 | 复合 openai | 有(SDK-HMAC 自签,已实现) | +| `channels-trae` | **有(本次新加)** | 无 | **SOLO 自定义(本次新写)** | 已修 | +| `channels-gemini` | **★ 缺** | 有 | 自定义 | 静态头 + Bearer,**待确认** | +| `channels-zcode` | **★ 缺** | 无 | 无(继承 anthropic 基座) | **★ 高度可疑** | +| `channels-cline` | **★ 缺** | 无 | 套 openai | **★ 可疑**(要 `Bearer workos:`) | +| `channels-qoder` | **★ 缺** | 无 | 套 openai | **★ 可疑**(头由 WASM 生成) | + +内置适配器里 `build_headers` 只有 3 个有(buddy / kimicode / opencodezen), +其余 11 个(含 **anthropic 基座**)靠静态头 + 内核兜底 `Bearer`。 + +> **★ anthropic 基座本身没有 `build_headers`**,而 anthropic 协议实际要 +> `x-api-key` 而非 `Authorization: Bearer`。`channels-zcode` 继承它 ⇒ +> 一旦有账号就会立刻暴露,与 trae 修复前完全同型。 + +### 1.3 已验证可用的通道(生产实测,不是单测) + +| 模型(源限定名) | 结果 | +|---|---| +| `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` | ✅ 80 帧,正文 27 + 推理 299 | +| `channels-trae-b8dd6b3df30e:glm-5.2` | ✅ OK | +| `channels-buddy-buddy-main:hy4-preview` | ✅ OK | + +| 端点 | 结果 | +|---|---| +| `channels/credits/status` (trae) | ✅ `active:true, dailyCredit:100, todayCheckedIn:true` | +| `channels/credits/balances` (trae) | ✅ `total:598.516 credits` | + +### 1.4 本轮已修(代码已部署生产,**尚未提交 git**) + +**① 网关解不开 `源名:模型名` 限定名**(`internal/provider/registry.go`) + +- 根因:`ResolvePinned` 用 `strings.IndexAny(model, "-:/")` 找**第一个**分隔符。 + 渠道源名形如 `channels-trae-b8dd6b3df30e`(**含 `-`**)⇒ 切成 `src="channels"` + ⇒ 永不匹配 ⇒ `Resolve()` 返回 nil ⇒ `model "..." is not configured`。 +- 为什么必须支持:本实例 **44 个模型被多源同时提供**(`glm-5.2` 由 6 个源提供), + WebUI 下拉框用 `源名:模型名` 作 option value 是**有意设计**。 +- 修法:抽 `splitPinned()`,**按已知源名做最长前缀匹配**(源名是已知集合, + 唯一可靠办法),保留旧 `-:/` 兜底。`EffectiveModel` 同步修。 +- 判据:`internal/provider/registry_pinned_name_test.go`(4 用例,变异 1/1 咬住)。 + +**② trae 适配器三连错**(`internal/lua/plugins/channels.lua`,逐层剥开) + +1. **没有 `build_headers`** ⇒ 内核兜底发 `Bearer`,而 trae 推理端点要 + `Cloud-IDE-JWT` + 设备身份头 ⇒ 401「not able to authenticate you」。 + → 与「额度不可用」是**同一根因的两个出口**(额度端点当轮修了、推理端点漏了)。 +2. **header 常量值是错的**:`X-App-Id` 抄成 `6eefa6c9`(真实 + `6eefa01c-1036-4c7e-9ca5-d891f63bfcd8`)、`app_version_code` 用 `0.1.52` + (真实是**日期式** `20260811`)⇒ 上游 `4001 binding: expr_path=app_version_code, + cause=parameter type does not match binding data`。 +3. **流式转换完全不对**:TRAE 是 **SOLO 自定义 SSE** + (`event:output` → `data:{"response":"###","reasoning_content":...}`)。 + 套 `openai_stream_chunk` ⇒ 无 `choices` ⇒ `empty stream`; + 改成 OpenAI 外壳 `{choices:[{delta}]}` **仍不对** —— 内核 + `json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构**,外壳字段全丢。 + ⇒ 必须输出扁平 `{content, reasoning_content, usage, done, finish_reason}`。 +- 判据:`channels_trae_chat_headers_test.go`(3)、`channels_trae_solo_sse_test.go`(2), + 变异 2/2 咬住。 + +**③ 余额恒为 0**(同文件 `balance.total_fn`) + +- 根因:读 `p.credits_limit`(pack **顶层**)—— 该字段**根本不存在**。 + 真实路径是 `pack.entitlement_base_info.quota.credits_limit` + (参考 `trae-credits.ts:341`)+ `pack.usage.credits_amount`。 +- 实测上游:`免费`(无 credits_limit) / `每月登录赠送`(500) / `签到奖励`(100) = **600**。 +- 修法:读嵌套路径 + 保留顶层兼容 + `usage_summary.{total,consumed}_amount` 兜底。 +- 判据:`channels_trae_credits_hdr_test.go`(1)。 +- 修后余额 **600 → 实测 598.516**(真实扣减)。 + +**④ 附带**:`channels_trae_test.go` 夹具从**扁平** `{"Token":...}` 改为真实 +`{"Result":{"Token":...}}` —— 原来夹具形状与生产不一致,**判据必然虚假通过** +(这是 ExchangeToken 缺陷藏了很久的原因)。 + +## 2. 后续计划 + +### P0 — 提交本轮修复(必须先做) + +代码已部署生产但**未进 git**。必须: +1. 在 `release/v2.0.x` 上提交(含 3 个源文件 + 6 个判据文件) +2. 打 tag `v2.0.40`(**这是内核 Go 改动**:`registry.go` ⇒ 真核心版本) +3. 回流 main(cherry-pick,不 merge) +4. 复核 tag 落点 + release vs main 零差异 + 生产 sha256 一致 + +> 判据:`git tag --points-at ` 非空;`git diff release/v2.0.x main` 为空。 + +### P1 — 其余渠道的同类缺陷(**账号 0,验证不了**) + +按"风险 × 可验证性"排序。**核心限制:这 4 个平台账号数均为 0, +无法做生产实测** —— 只能靠静态对齐参考 + 装好适配器后看 `__adapter_install_probe`。 + +| 优先 | 项 | 缺陷 | 修法 | 可验证性 | +|---|---|---|---|---| +| P1-1 | `channels-zcode` | 走 anthropic 基座,而**基座无 `build_headers`**,anthropic 要 `x-api-key` 不是 Bearer | 给基座补 `build_headers`(`x-api-key` + `anthropic-version`),zcode 继承即得 | 静态可验;运行需账号 | +| P1-2 | `channels-cline` | 参考要 `Bearer workos:`,我们的 token 无 `workos:` 前缀 | 登录/续期处拼前缀,或适配器 `build_headers` 补 | 静态可验;运行需账号 | +| P1-3 | `channels-gemini` | 有静态头 + Bearer(参考注释也说 Cloud Code 用 OAuth Bearer)⇒ **可能本来就对** | 只需确认,不改 | 静态可验 | +| P1-4 | `channels-qoder` | 头由 **WASM 生成必须原样透传**,我们拿不到 `.wasm` | **阻塞**:二进制不在仓库任何位置 | 不可验证 | + +### P2 — 内核对"限定名"的契约收口 + +`ResolvePinned` / `EffectiveModel` 现在各自调 `splitPinned`。建议: +- 把"源名可含哪些字符"写进 `docs/`,并加判据:新增源名若含 `:` 或 `/` 必须报警 + (否则限定名会歧义)。 +- 现状:源名由插件生成 `channels--`,只含 `-`/字母数字,安全。 + +### P3 — 判据防回归(防"夹具与生产不一致"再犯) + +本轮最大的教训不是某个 bug,而是**夹具形状错 ⇒ 判据虚假通过**(trae 活了两轮)。 + +建议加一条**通用**判据:`TestAdaptersFixturesMatchRealUpstreamShape` —— +对每个有夹具的渠道,把夹具响应体与 +`/tmp/pi-github-repos/.../vendor/channel-pack/src/*.ts` 里注释记录的真实响应形状 +做**结构对照**(键集合),不一致即报红。做不到全自动,至少把已知的 +"Result 信封 / SOLO SSE / 嵌套 quota" 三类钉住。 + +### P4 — 待用户决策 + +| 项 | 需要什么 | +|---|---| +| zcode 真实登录 | 用户点授权(身份块 + init-poll 流已就绪) | +| cline / qoder / gemini / codearts / minimax / lobsterai / opencode | 需各平台账号;`device-poll` 系列要用户去官方页输码 | +| raccoon / loomy | 需扫码 | +| qoder WASM | 用户提供 `.wasm` | + +## 3. 风险与纪律(本轮新增) + +1. **`luac -p` 对长字符串内的适配器代码无效**(只看外层)⇒ 会得到假绿。 + **必须**用 `__adapter_install_probe` 看真实装载结果。本轮我因此把 TRAE 的 + SSE 代码误塞进 CODEARTS 适配器,导致 codearts 装载失败而 `luac` 一直通过。 +2. **Python 字符串替换会静默不生效**:锚文本缩进不符 ⇒ 匹配 0 次。 + 规矩:① 先 `cp` 备份 ② **行号定位** ③ 写后 `luac -p` + `grep -c` 双确认 + ④ 长字符串内的改动必须验装载。 +3. **`function` 是 Lua 关键字**:`cc.function` 是语法错误,必须 `cc["function"]`; + 但批量替换时别把 `cc.function_call` 一起改坏(本轮踩过,导致 + `')' expected near '_call'`)。 +4. **判据要咬被测对象**:本轮 `channels_trae_credits_hdr_test.go` 第一版造 HTTP 假上游, + 因 URL 覆盖/凭据 ref/路径匹配任一环错,"没发请求"伪装成"头不对"(耗了三轮)。 + 改成直接咬 `spec.headers_fn` 的真实产出,一轮就准。 +5. **只改插件不必换二进制**;改内核 Go 必须换。混着发会把"插件改动"记成"核心版本" + (v2.0.21–v2.0.39 有 18 个 tag 是这么来的,见 §4)。 +6. **`/tmp` 是 8.8G tmpfs**,Go 链接失败常见于它满(`no space left on device`)。 + 元凶通常是 `/tmp/gocache`(可安全清)。 + +## 4. 账务实况(必须记住) + +| 事实 | 值 | +|---|---| +| 内核 Go 代码自 v2.0.20 起的**全部**改动 | 仅 `internal/lua/adapter_prelude.go`、`internal/provider/provider.go`、`internal/provider/registry.go` | +| 只改插件却发了核心 tag 的版本 | v2.0.21–v2.0.39 中的 **18 个** | +| 当前最新 tag | `v2.0.39`(`091f790`),落点正确 | +| main | `56ea269`(与 release 内容零差异) | +| **未提交的改动** | 本轮三个修复(见 §1.4),已部署生产 | + +> ⇒ **版本号语义教训**:以后"改插件不打核心 tag",插件自身维护版本; +> 内核版本只在 Go 代码变更时递增。 diff --git a/internal/core/channels_trae_chat_headers_test.go b/internal/core/channels_trae_chat_headers_test.go new file mode 100644 index 0000000..28620f4 --- /dev/null +++ b/internal/core/channels_trae_chat_headers_test.go @@ -0,0 +1,117 @@ +package core + +import ( + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// ★ TRAE 的**推理请求**必须用 Cloud-IDE-JWT 头族,不是 Bearer。 +// +// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17): +// +// Doubao-Seed-2.1-Pro / seed-code-pro-0430 / glm-5-turbo 全部 +// 401「We're sorry, but we are not able to authenticate you」 +// 因为 channels-trae 适配器**只有 4 个 transform、没有 build_headers** +// ⇒ 内核兜底发 `Authorization: Bearer `。 +// +// 参考 trae.ts 端点表:对话也是 Cloud-IDE-JWT(traeSOLOHeaders)。 +// +// ★ 这与「额度不可用」是**同一根因的两个出口**:额度端点(checkin_status / +// +// balance)漏了头、已修;推理端点(本判据)也漏了,但当时没一起修。 +// ⇒ 教训:修「某一族请求的鉴权」时必须把所有**该族的出口**列出来逐个对齐。 +func TestTraeChatHeadersUseCloudIDEJWT(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatalf("加载插件: %v", err) + } + + // 走真实的 BuildHeaders(内核调用适配器的那条路径) + hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{ + "api_key": "ACCESS-TOKEN-X", + "source": map[string]interface{}{ + "name": "channels-trae-test", + "meta": map[string]interface{}{ + "uid": "3428695215186675", + "device_id": "dev-abc", + "machine_id": "mach-xyz", + }, + }, + }) + if err != nil { + t.Fatalf("BuildHeaders: %v", err) + } + auth := hdrs["Authorization"] + if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") { + t.Fatalf("★ 推理请求鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+ + " 用 Bearer 会得到 401「not able to authenticate you」,"+ + "表现为「channel 注册的模型全都不能用」", auth) + } + if !strings.Contains(auth, "ACCESS-TOKEN-X") { + t.Fatalf("Authorization 应携带 access_token,实得 %q", auth) + } + // 设备身份头必须来自 meta(适配器沙箱读不到凭据) + for k, want := range map[string]string{ + "X-Uid": "3428695215186675", + "X-Device-Id": "dev-abc", + "X-Machine-Id": "mach-xyz", + } { + if got := hdrs[k]; got != want { + t.Fatalf("★ %s 应为 %q(来自源 meta),实得 %q", k, want, got) + } + } + // 参考 traeSOLOHeaders 的成对 token 头 + for _, k := range []string{"X-Cloudide-Token", "X-Ide-Token"} { + if hdrs[k] != "ACCESS-TOKEN-X" { + t.Fatalf("%s 应与 access_token 相同,实得 %q", k, hdrs[k]) + } + } + t.Logf("✓ trae 推理头: %s(X-Uid=%s)", auth, hdrs["X-Uid"]) +} + +// 配对判据:源 meta 缺失时不得崩、也要给出 Cloud-IDE-JWT(只是没有设备头)。 +func TestTraeChatHeadersTolerateMissingMeta(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{ + "api_key": "T", + }) + if err != nil { + t.Fatalf("BuildHeaders 不该因缺 meta 报错: %v", err) + } + if !strings.HasPrefix(hdrs["Authorization"], "Cloud-IDE-JWT ") { + t.Fatalf("即使没有 meta 也必须是 Cloud-IDE-JWT,实得 %q", hdrs["Authorization"]) + } +} + +// ★ 源必须携带设备身份(meta),否则适配器拿不到 uid。 +func TestTraeSourceCarriesDeviceIdentityInMeta(t *testing.T) { + b, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + s := string(b) + if !strings.Contains(s, "source_meta_from_credential") { + t.Fatal("trae 平台应声明 source_meta_from_credential=true —— " + + "否则源的 meta 为空、适配器无法派生 X-Uid / X-Device-Id") + } + if !strings.Contains(s, "src.meta") { + t.Fatal("syncAccountSource 应把设备身份写进 src.meta") + } +} + +var _ = lua.HandlerRequest{} diff --git a/internal/core/channels_trae_credits_hdr_test.go b/internal/core/channels_trae_credits_hdr_test.go new file mode 100644 index 0000000..8752232 --- /dev/null +++ b/internal/core/channels_trae_credits_hdr_test.go @@ -0,0 +1,71 @@ +package core + +import ( + "strings" + "testing" +) + +// ★ TRAE 的**签到状态 / 积分余额**必须用 Cloud-IDE-JWT + 设备身份头, +// 不能用默认的 `Authorization: Bearer`。 +// +// 真实缺陷(用户报「显示额度不可用」,2026-10-17): +// +// 同一账号**签到领取成功**(claim_fn 走 traceCheckinHeaders), +// 但 credits/status 返回 +// "We're sorry, but we are not able to authenticate you..." +// 因为 checkin_status / balance 既没 auth 也没 headers_fn +// ⇒ fetchAccountJSON 退回默认 Bearer。 +// +// 参考 trae.ts 端点表逐行写明这三个端点都是 Cloud-IDE-JWT。 +// +// ⚠️ 只加 `auth = "cloud-ide-jwt"` **没用**:该字段只被 claim_fn 的绑定循环 +// +// 读取(line ~9033),fetchAccountJSON 只认 headers_fn —— 第一版我就只加了 +// 声明,等于没改。 +// +// ⚠️ 为什么不用 httptest 假上游断言:那需要 set_endpoints 改 URL + 凭据落到 +// +// 池账号 ref + 路径匹配全部对齐,任一环节错都会让"没发请求"看起来像 +// "头不对"(我在这上面耗了三轮,全是夹具问题)。判据直接咬**被测对象**: +// spec.headers_fn 的产出。 +func TestTraeCreditsEndpointsUseCloudIDEJWT(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatalf("加载插件: %v", err) + } + + // 通过真实函数取 trae 的两个 spec 的 headers_fn 产出。 + // 凭据带 uid ⇒ 设备身份头由 uid 确定性派生。 + out, err := ps.Call("channels", "__cred_headers_probe", map[string]interface{}{ + "platform": "trae", + "credential": `{"access_token":"ACCESS-X","uid":"3428695215186675",` + + `"device_id":"d1","machine_id":"m1"}`, + }) + if err != nil { + t.Fatalf("__cred_headers_probe: %v", err) + } + j := jsonOf(t, out) + for _, which := range []string{"checkin_status", "balance"} { + h, ok := j[which].(map[string]interface{}) + if !ok { + t.Fatalf("探针应回报 %s 的头,实得 %v", which, j) + } + auth, _ := h["Authorization"].(string) + if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") { + t.Fatalf("★ %s 的鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+ + " 用默认 Bearer 会得到「not able to authenticate you」,"+ + "而同一账号的签到领取却是成功的 —— 这个矛盾就是缺陷", which, auth) + } + for _, k := range []string{"X-Device-Id", "X-Market-User-Id", "X-User-Region"} { + if v, _ := h[k].(string); v == "" { + t.Fatalf("★ %s 缺 %s —— 参考实测简化头会失败", which, k) + } + } + t.Logf("✓ %s: %s (device=%v)", which, auth, h["X-Device-Id"]) + } +} diff --git a/internal/core/channels_trae_exchange_shape_test.go b/internal/core/channels_trae_exchange_shape_test.go new file mode 100644 index 0000000..52cbc01 --- /dev/null +++ b/internal/core/channels_trae_exchange_shape_test.go @@ -0,0 +1,124 @@ +package core + +import ( + "fmt" + "strings" + "sync/atomic" + "testing" + + "llmsproxy/internal/lua" +) + +// ★ TRAE ExchangeToken 的凭据在 **Result 里**,不在顶层。 +// +// 真实缺陷(用户报「trae 登录还是不行」,2026-10-17): +// +// 会话落定为 state=failed,message = "TRAE ExchangeToken 响应缺少 Token" +// 而实测上游 HTTP 200,body 是: +// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}} +// 代码读 `rec.Token` ⇒ 恒 nil ⇒ 误报「缺少 Token」。 +// 参考 parseTraeExchangeResponse 明确先取 `data.Result ?? data.result`。 +// +// ★★ 为什么此前所有 trae 判据都是绿的(这个缺陷藏了很久的原因): +// +// **测试夹具返回的是扁平 `{"Token":...}`** —— 与生产形状不一致, +// 于是「只读顶层」这个错误在夹具里恰好是对的。夹具形状错 = 判据必然虚假通过。 +// 已把夹具改成真实 Result 形状(channels_trae_test.go)。 +// +// 本判据直接用**两种形状**分别打一次,确保实现同时认 Result 与顶层兜底。 +func TestTraeExchangeReadsResultEnvelope(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + { + name: "生产形状:凭据在 Result 里", + body: `{"ResponseMetadata":{"RequestId":"x"},"Result":{"Token":"ACCESS-FROM-RESULT",` + + `"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000}}`, + want: "ACCESS-FROM-RESULT", + }, + { + name: "兜底:凭据在顶层(上游若改版)", + body: `{"Token":"ACCESS-FROM-TOP","RefreshToken":"RT-TOP"}`, + want: "ACCESS-FROM-TOP", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newTraeHarness(t) + // 覆盖夹具的响应体为这个形状 + atomic.StoreInt32(&h.exchOK, 1) + h.overrideExchangeBody(tc.body) + + // ★ 必须走**池路径**(UI 真实流程):先建占位账号再带 account_id + // 发登录。beginAndCallback 不带 account_id ⇒ 凭据落到 + // `channels:trae`(无池账号),也就无法回读 —— 我第一版就这么写, + // 判据报"池里应有账号,实得 0",与真因(形状)无关。 + aid, nonce := h.addAccountAndStartLogin("trae") + status, body := h.invokeCallback(map[string]string{ + "state": nonce, "refreshToken": "RT-FROM-CALLBACK", + "userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`, + }) + if status != 200 { + t.Fatalf("回调应成功:status=%d body=%s", status, body) + } + if strings.Contains(body, "缺少 Token") { + t.Fatalf("★ 又没解出 Token(形状=%s)—— 检查是否只读了顶层", tc.name) + } + // 凭据必须真的落进 secrets,且新 refresh 被轮换保存。 + // ⚠️ 账号 id 是**随机生成**的(account_add 用 newToken():sub(1,12)), + // 不能硬编码 —— 用 account_list 拿真实 id。 + cred := h.credentialFor(aid) + if !strings.Contains(cred, tc.want) { + t.Fatalf("落盘凭据里应有 access token %q,实得 %q", tc.want, cred) + } + if !strings.Contains(cred, "refresh_token") { + t.Fatalf("★ 轮换后的 refreshToken 必须保存 —— 旧 refresh 已被上游作废,"+ + "不存新值会让下次续期必然失败。实得 %q", cred) + } + t.Logf("✓ %s → access=%s", tc.name, tc.want) + }) + } +} + +// overrideExchangeBody 让夹具的 ExchangeToken 返回指定 body。 +func (h *traeHarness) overrideExchangeBody(body string) { + h.t.Helper() + h.exchBody = body +} + +// credentialFor 读回某账号落盘的凭据原文。 +// +// 用 Plugins.GetSecret(内核级读取)而不是 __read_cred_probe: +// 探针那版我传错过入参(它吃 ref),返回 len:0,让判据看起来像"凭据没落盘"。 +// GetSecret 是 TestTraeCallbackPoolPath 已在用的正规入口。 +// addAccountAndStartLogin 建占位账号并带 account_id 发起登录,返回 (账号id, nonce)。 +// 这是 UI 真实流程(见 TestTraeCallbackPoolPath)。 +func (h *traeHarness) addAccountAndStartLogin(platform string) (string, string) { + h.t.Helper() + add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{ + Method: "POST", Body: fmt.Sprintf(`{"platform":%q}`, platform), + }) + if err != nil { + h.t.Fatal(err) + } + aid, _ := add.JSON.(map[string]interface{})["account_id"].(string) + out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ + Method: "POST", Body: fmt.Sprintf(`{"platform":%q,"account_id":%q}`, platform, aid), + }) + if err != nil { + h.t.Fatal(err) + } + nonce, _ := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string) + return aid, nonce +} + +func (h *traeHarness) credentialFor(accountID string) string { + h.t.Helper() + v, ok, err := h.ps.GetSecret("channels", "channels:trae:"+accountID) + if err != nil || !ok { + return "" + } + return v +} diff --git a/internal/core/channels_trae_solo_sse_test.go b/internal/core/channels_trae_solo_sse_test.go new file mode 100644 index 0000000..5e3593d --- /dev/null +++ b/internal/core/channels_trae_solo_sse_test.go @@ -0,0 +1,130 @@ +package core + +import ( + "encoding/json" + "strings" + "testing" +) + +// ★ TRAE 的流式转换必须把 **SOLO 自定义 SSE** 摊平成内核要的 **UnifiedChunk**。 +// +// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17): +// +// ① 我原先 `return __prelude.openai_stream_chunk(raw_chunk)` —— SOLO 帧形如 +// {"response":"###","reasoning_content":null,"tool_calls":null} +// 没有 `choices` 字段 ⇒ 每帧都解析不出 ⇒ 上游 HTTP 200 + 118 正常帧, +// 客户端却得到 `empty stream`。 +// ② 修了 ① 之后我返回 OpenAI 外壳 `{choices:[{delta:{...}}]}` —— 仍不对: +// 内核 `json.Unmarshal(unified, &UnifiedChunk)` 直接解成**扁平结构**, +// 外壳字段全被丢弃 ⇒ 91 帧全是空 content。 +// +// ⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃), +// +// 因此判别帧类型只能靠内容特征。 +func TestTraeSoloSSEToUnifiedChunk(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + + // ① 正文帧 → content(**扁平**,不是 choices[].delta) + out, err := c.VM().Transform("channels-trae", "transform_stream_chunk", + `{"response":"Hello","reasoning_content":null,"tool_calls":null}`) + if err != nil { + t.Fatalf("transform: %v", err) + } + var ck map[string]interface{} + if err := json.Unmarshal([]byte(out), &ck); err != nil { + t.Fatalf("产出不是 JSON: %q", out) + } + if got, _ := ck["content"].(string); got != "Hello" { + t.Fatalf("★ 正文必须摊平到顶层 content,实得 %q(原始产出 %s)\n"+ + " 返回 {choices:[{delta:...}]} 外壳会被内核丢弃", got, out) + } + if _, has := ck["choices"]; has { + t.Fatalf("★ 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析,外壳会被忽略: %s", out) + } + + // ② 推理帧 → reasoning_content + out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", + `{"response":"","reasoning_content":"We","tool_calls":null}`) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(out), &ck); err != nil { + t.Fatalf("推理帧产出不是 JSON: %q", out) + } + if got, _ := ck["reasoning_content"].(string); got != "We" { + t.Fatalf("★ 推理必须进 reasoning_content,实得 %q(%s)", got, out) + } + + // ③ done 帧 + out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", `{"finish_reason":"stop"}`) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(out), &ck); err != nil { + t.Fatalf("done 帧产出不是 JSON: %q", out) + } + if ck["done"] != true { + t.Fatalf("★ done 帧必须 done=true,实得 %s", out) + } + if ck["finish_reason"] != "stop" { + t.Fatalf("finish_reason 应为 stop,实得 %s", out) + } + + // ④ usage 帧 + out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", + `{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}`) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(out), &ck); err != nil { + t.Fatalf("usage 帧产出不是 JSON: %q", out) + } + u, _ := ck["usage"].(map[string]interface{}) + if u == nil || u["total"] != 15.0 { + t.Fatalf("★ usage 必须摊平为 {prompt,completion,total},实得 %s", out) + } + + // ⑤ 工具调用:function_call → function 归一化 + out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", + `{"response":"","reasoning_content":null,"tool_calls":[{"function_call":{"name":"f","arguments":"{}"}}]}`) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "function_call") { + t.Fatalf("★ SOLO 的 function_call 必须归一化为 function,实得 %s", out) + } + if !strings.Contains(out, `"function"`) { + t.Fatalf("工具调用缺 function 字段: %s", out) + } + t.Logf("✓ SOLO SSE 全部摊平正确") +} + +// 配对判据:无关帧不得产出垃圾。 +func TestTraeSoloSSEDropsUnknownFrames(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + // metadata 帧(上游确实会发,且无正文) + out, err := c.VM().Transform("channels-trae", "transform_stream_chunk", + `{"model":"","session_id":"abc","prompt_completion_id":0}`) + if err != nil { + t.Fatalf("metadata 帧不该报错: %v", err) + } + if out != "" { + t.Fatalf("无正文的 metadata 帧应产出空串(不制造垃圾帧),实得 %q", out) + } +} diff --git a/internal/core/channels_trae_test.go b/internal/core/channels_trae_test.go index 6b6e895..24a323f 100644 --- a/internal/core/channels_trae_test.go +++ b/internal/core/channels_trae_test.go @@ -16,11 +16,12 @@ import ( // traeHarness:TRAE callback 流的测试台。假上游模拟 ExchangeToken; // 回调用 InvokeHandler 直打 oauth_callback(Query 表 = 平台重定向回来的参数)。 type traeHarness struct { - t *testing.T - ps *lua.Plugins - ts *httptest.Server - exchHit int32 - exchOK int32 // 1=成功 0=失败(atomic) + t *testing.T + ps *lua.Plugins + ts *httptest.Server + exchHit int32 + exchOK int32 // 1=成功 0=失败(atomic) + exchBody string // 非空时覆盖夹具的响应体(供形状判据用) } func newTraeHarness(t *testing.T) *traeHarness { @@ -45,7 +46,23 @@ func newTraeHarness(t *testing.T) *traeHarness { t.Errorf("ExchangeToken 请求体缺 %s: %s", want, body) } } - fmt.Fprintf(w, `{"Token":"ACCESS-%d","RefreshToken":"RT-NEW-%d"}`, n, n) + // ★★ 必须是**真实上游的形状**:凭据在 `Result` 里,不在顶层。 + // + // 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken): + // {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"...", + // "TokenExpireAt":...,"RefreshExpireAt":...}} + // + // ⚠️ 这条夹具原先返回**扁平** `{"Token":...}` —— 于是「代码只读顶层」 + // 这个真缺陷被夹具**完美掩盖**:所有 trae 判据都绿,真实登录却报 + // 「响应缺少 Token」。夹具形状与生产不一致 = 判据必然虚假通过。 + if h.exchBody != "" { + fmt.Fprint(w, h.exchBody) + return + } + fmt.Fprintf(w, `{"ResponseMetadata":{"RequestId":"r%d"},`+ + `"Result":{"ClientID":"en1oxy7wnw8j9n","Token":"ACCESS-%d",`+ + `"RefreshToken":"RT-NEW-%d","TokenExpireAt":1800000000000,`+ + `"TokenExpireDuration":1209600000,"RefreshExpireAt":1807755929432}}`, n, n, n) })) t.Cleanup(h.ts.Close) diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 77eb7cf..425b87b 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -116,7 +116,7 @@ plugin.kernel = { -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -153,6 +153,21 @@ local lobsteraiClientVersion local expiringWindowDays local fetchAccountJSON +-- ★★ 这批前向声明必须在 PLATFORMS **之前**:平台表里的 headers_fn / claim_fn +-- 闭包会在**运行时**调用这些函数,而闭包捕获的是**词法作用域里的那个变量**。 +-- 若声明写在后面(例如 line ~5479),PLATFORMS 段里的引用会被解析成**全局** +-- `traeCheckinHeaders`(nil),而后面的 `local` 是**另一个变量**,赋不到它 +-- ⇒ 调用点永远拿到 nil。 +-- +-- 真实缺陷(2026-10-17 用户报「显示额度不可用」): +-- trae 的 checkin_status.headers_fn 调 traeCheckinHeaders(...) 返回 nil, +-- fetchAccountJSON 于是退回默认 `Authorization: Bearer ` +-- ⇒ 上游回「not able to authenticate you」;而同一账号的**签到领取是成功的** +-- (claim_fn 那条路同样拿到 nil,但它内部有 Bearer 兜底且 claim 端点接受)。 +-- ⇒ 教训:Lua 的 local 只对**其后的**代码可见;闭包不是延迟绑定的魔法。 +local traeCheckinHeaders, traeFixNickname +local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId + local PLATFORMS = { -- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)===== -- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的 @@ -233,6 +248,10 @@ local PLATFORMS = { -- TRAE 非 OpenAI 兼容,用**本插件自装**的 SOLO 适配器 -- (见 TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。 adapter = "channels-trae", + -- ★ 让 syncAccountSource 把凭据里的设备身份放进源的 meta —— + -- trae 的推理头需要 X-Uid / X-Device-Id / X-Machine-Id 派生, + -- 而适配器沙箱里读不到凭据(只能用内核传进来的 meta)。 + source_meta_from_credential = true, -- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden -- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。 models = { @@ -260,6 +279,12 @@ local PLATFORMS = { checkin_status = { url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/status", method = "POST", body = "{}", + -- ★ 鉴权方案与 checkin 相同:Cloud-IDE-JWT + 设备身份头。 + -- 参考 trae.ts 端点表:签到状态 / 积分余额 皆 Cloud-IDE-JWT。 + -- headers_fn 在**文件末尾的绑定循环**里接上(auth="cloud-ide-jwt" 段): + -- 内联定义会因作用域顺序拿不到 traeCheckinHeaders / platformById, + -- 闭包捕获 nil、错误被 pcall 吞掉 ⇒ **静默退回 Bearer** ⇒ + -- 上游「not able to authenticate you」⇒ 用户看到「额度不可用」。2026-10-17。 status_fn = function(rec) return { active = rec.enable == true, @@ -272,14 +297,54 @@ local PLATFORMS = { balance = { url = "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage", method = "POST", body = '{"require_usage":true,"req_source":2}', + -- 同上:积分余额同为 Cloud-IDE-JWT;headers_fn 在末尾绑定循环里接上。 + -- ★★ 字段在**两层嵌套**里,不在 pack 顶层: + -- pack.entitlement_base_info.quota.credits_limit ← 额度上限 + -- pack.usage.credits_amount ← 已用 + -- 参考 trae-credits.ts:341 `const quota = base.quota` + + -- `readNumber(quota,'credits_limit')` 逐字如此。 + -- + -- 真实缺陷(2026-10-17 用户报「额度显示依旧异常」): + -- 我此前读的是 `p.credits_limit`(pack 顶层)—— 该字段**根本不存在** + -- ⇒ 每个包都算 0 ⇒ 总余额恒 0,而账号里其实有 600(每月登录 500 + 签到 100)。 + -- 实测上游响应(2026-10-17): + -- pack[0] 免费 quota 无 credits_limit → 跳过 + -- pack[1] 每月登录赠送 quota.credits_limit=500 usage={} + -- pack[2] 签到奖励 quota.credits_limit=100 usage={} + -- usage_summary: {total_amount:600, consumed_amount:0} total_fn = function(rec) local sum = 0 local list = rec.user_entitlement_pack_list or rec.userEntitlementPackList or {} if type(list) ~= "table" then return 0 end for _, p in ipairs(list) do - local lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0 - local amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0 - sum = sum + math.max(0, lim - amt) + local base = p.entitlement_base_info or p.entitlementBaseInfo + local quota = (type(base) == "table" and (base.quota or base.Quota)) or nil + local lim, amt = 0, 0 + if type(quota) == "table" then + lim = tonumber(quota.credits_limit) or tonumber(quota.creditsLimit) or 0 + end + -- 兼容:少数响应把额度放在 pack 顶层(保留旧路径,不丢) + if lim == 0 then + lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0 + end + local usage = p.usage or p.Usage + if type(usage) == "table" then + amt = tonumber(usage.credits_amount) or tonumber(usage.creditsAmount) or 0 + end + if amt == 0 then + amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0 + end + if lim > 0 then sum = sum + math.max(0, lim - amt) end + end + -- 兜底:所有包都没有 credits_limit 时,用 usage_summary 的总量减消耗。 + -- (实测「免费」包就是这样:quota 里只有 enable_* 布尔,无额度字段。) + if sum == 0 then + local us = rec.usage_summary or rec.usageSummary + if type(us) == "table" then + local tot = tonumber(us.total_amount) or tonumber(us.totalAmount) or 0 + local used = tonumber(us.consumed_amount) or tonumber(us.consumedAmount) or 0 + if tot > 0 then sum = math.max(0, tot - used) end + end end return sum end, @@ -3934,7 +3999,25 @@ local function syncAccountSource(provider, accountId) -- 不透明字符串用 —— 不需要内核知道任何厂商知识。 if plat.endpoint then src.endpoint = plat.endpoint end if plat.endpoint_stream then src.endpoint_stream = plat.endpoint_stream end - if plat.headers_extra then src.headers = plat.headers_extra end + if plat.headers_extra then src.headers = plat.headers_extra end + -- ★ 把**设备身份**随源带给适配器:有些平台的头不是简单 Bearer, + -- 需要 uid / device_id / machine_id 派生(trae 的 X-Uid / X-Device-Id / + -- X-Machine-Id)。适配器沙箱里**读不到凭据**,只能靠 meta 传。 + -- + -- 真实缺陷(2026-10-17 用户报「无法使用 channel 注册的模型」): + -- 此前 meta 为空 ⇒ 适配器没法构造 Cloud-IDE-JWT 头族 ⇒ 内核兜底发 + -- `Authorization: Bearer ` ⇒ 上游 401 + -- 「We're sorry, but we are not able to authenticate you」。 + -- 这与「额度不可用」是**同一根因的两个出口**(额度端点修了、推理端点没修)。 + if plat.source_meta_from_credential then + local cmeta = credentialFull(acct.credentialRef) + if type(cmeta) == "table" then + src.meta = {} + for _, mk in ipairs({ "uid", "device_id", "machine_id", "enterprise_id" }) do + if cmeta[mk] ~= nil and cmeta[mk] ~= "" then src.meta[mk] = cmeta[mk] end + end + end + end local r, aerr = __llmsproxy_kernel.call("add_source", { source = src }) -- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会 -- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。 @@ -5454,10 +5537,6 @@ end -- 之前**占坑。此前它们是全局函数,靠「全局表在运行时才解析」侥幸工作; -- 改成 local 后这个隐式依赖就断了 —— 表现为 -- attempt to call global 'traeCallback' (a nil value)。 -local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId - -local traeCheckinHeaders, traeFixNickname - function plugin.poll_login(req) local id = (req and req.query and req.query.id) or "" sweepSessions() @@ -8807,11 +8886,35 @@ traeCallback = function(plat, q, s) if not ok or type(rec) ~= "table" then return nil, nil, "TRAE ExchangeToken 响应无法解析" end - local access = rec.Token or rec.token or rec.AccessToken or rec.access_token + -- ★★ 上游把凭据放在 **Result 里**,不是顶层: + -- {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}} + -- 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken):HTTP 200, + -- 顶层只有 ResponseMetadata + Result、**没有** Token。 + -- 我原先读 `rec.Token` ⇒ 恒 nil ⇒ 报「响应缺少 Token」;用户只看到「登录失败」, + -- 完全看不出是解包层级错了。参考 parseTraeExchangeResponse 同样先取 + -- `data.Result ?? data.result`。 + -- ⚠️ 顶层也兜(上游可能改版),但**优先 Result**。 + local r2 = rec.Result or rec.result + if type(r2) ~= "table" then r2 = rec end + local access = r2.Token or r2.token or r2.AccessToken or r2.access_token + or rec.Token or rec.token if not access or access == "" then - return nil, nil, "TRAE ExchangeToken 响应缺少 Token" + return nil, nil, "TRAE ExchangeToken 响应缺少 Token(Result 与顶层都没有)" end - return packTrae(access), account, nil + -- ★ 轮换后的 refreshToken 必须留下:旧 refresh 已被上游作废 + -- (参考注释明说「换新 access 并**轮换** refreshToken」)。不存新值 ⇒ 下次续期必失败。 + local newRefresh = r2.RefreshToken or r2.refreshToken or rec.RefreshToken or rec.refreshToken + local expireAt = r2.TokenExpireAt or rec.TokenExpireAt + local packed = packTrae(access) + if (newRefresh and newRefresh ~= "") or expireAt then + local okc, cred = pcall(json.decode, packed) + if okc and type(cred) == "table" then + if newRefresh and newRefresh ~= "" then cred.refresh_token = newRefresh end + if expireAt then cred.expires_at = math.floor((tonumber(expireAt) or 0) / 1000) end + packed = json.encode(cred) + end + end + return packed, account, nil end -- 分支 2:无 refreshToken,用 userJwt.Token 兜底 @@ -8995,6 +9098,31 @@ end do for _, p in ipairs(PLATFORMS) do if p.checkin and p.checkin.auth == "cloud-ide-jwt" then + -- ★★ 给**同一平台的** checkin_status / balance 接上同一套头。 + -- + -- 为什么必须在这里而不是表内联:PLATFORMS 定义在文件最前(~170 行), + -- 那时 traeCheckinHeaders(~9030 才赋值)还是 nil、platformById 也只是 + -- 尚未定义的 local ⇒ 闭包捕获成 nil ⇒ 上层 pcall 吞掉错误 + -- ⇒ **静默退回 `Authorization: Bearer`** ⇒ 上游回 + -- 「not able to authenticate you」,用户看到「额度不可用」。 + -- 而同一账号的**签到领取是成功的**(claim_fn 就在本循环绑定)—— + -- 「能签到、额度却不可用」这个矛盾,根源就是两处头不同源。 + -- 真实缺陷:2026-10-17 用户报「显示额度不可用」。 + local mkHeaders = function(plat) + return function(accessToken, cred) + local c = (type(cred) == "table") and cred or {} + if not c.access_token then c.access_token = accessToken end + -- 传 plat 本身(不是 nil):traeCheckinHeaders 用 plat.ide_version + -- 取 App-Version,传 nil 会抛错并被上层 pcall 静默吞掉。 + return traeCheckinHeaders(plat, c, nil) + end + end + for _, specName in ipairs({ "checkin_status", "balance" }) do + local spec = p[specName] + if type(spec) == "table" and not spec.headers_fn then + spec.headers_fn = mkHeaders(p) + end + end p.claim_fn = function(plat, tok, args) local cred = (args and args.credential) or { access_token = tok } local uid = (args and args.uid) or cred.uid @@ -9148,6 +9276,7 @@ function adapter.transform_response(raw_body) end function adapter.transform_stream_chunk(raw_chunk) + -- CodeArts 的流式是 OpenAI 兼容格式,直接复用共享前导。 return __prelude.openai_stream_chunk(raw_chunk) end @@ -9186,6 +9315,66 @@ adapter.name = "channels-trae" adapter.version = "1.0.0" adapter.endpoint = "/api/agent/v3/llm_utils_chat" +-- ★★ build_headers:推理请求的 Cloud-IDE-JWT 头族。 +-- +-- 参考 trae.ts traeSOLOHeaders(逐项对齐)。端点表明确:**对话也是 Cloud-IDE-JWT**, +-- 不是 Bearer。没有这个钩子时内核兜底发 `Authorization: Bearer ` +-- ⇒ 上游 401「We're sorry, but we are not able to authenticate you」 +-- ⇒ 表现为「channel 注册的模型全都不能用」。 +-- +-- 真实缺陷(2026-10-17):本适配器只有 4 个 transform、**没有 build_headers**, +-- 而同一平台的签到/额度端点也漏了同一套头 —— 同一根因的两个出口。 +-- +-- meta 里可选带 uid / device_id / machine_id(由 syncAccountSource 从凭据取), +-- 因为适配器沙箱**读不到凭据**。 +function adapter.build_headers(meta) + local tok = "" + local uid, dev, mid = "", "", "" + if type(meta) == "table" then + if type(meta.api_key) == "string" then tok = meta.api_key end + local src = type(meta.source) == "table" and meta.source or {} + local m = type(src.meta) == "table" and src.meta or {} + uid = tostring(m.uid or "") + dev = tostring(m.device_id or "") + mid = tostring(m.machine_id or "") + end + -- ★★ 这些值是**准入条件**,不是装饰。逐项照参考 trae-product.ts 的 TRAE 配置: + -- appId = 6eefa01c-1036-4c7e-9ca5-d891f63bfcd8 (完整 UUID, + -- 我第一版抄成 "6eefa6c9" —— 前缀都不对) + -- ideVersionCode = 20260811 (**日期式**,不是 0.1.52) + -- ideVersion = 0.1.52 + -- deviceBrand = Apple + -- osVersion = macOS 15.7.4 + -- userAgent = Trae/0.1.52 (参考用这个,不是 VSCode 那串) + -- + -- 实测错误(2026-10-17):`app_version_code` 传 "0.1.52" 时上游回 + -- 4001 bad request: binding: expr_path=app_version_code, + -- cause=parameter type does not match binding data + -- ⇒ 它要的是**日期式数字串**,版本号格式会被类型校验拒掉。 + local h = { + ["Content-Type"] = "application/json", + ["Accept"] = "text/event-stream", + ["User-Agent"] = "Trae/0.1.52", + ["Authorization"] = "Cloud-IDE-JWT " .. tok, + ["X-Cloudide-Token"] = tok, + ["X-Ide-Token"] = tok, + ["X-App-Id"] = "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8", + ["X-App-Version"] = "default", + ["X-Ide-Version"] = "0.1.52", + ["X-Ide-Version-Code"] = "20260811", + ["X-App-Version-Code"] = "20260811", + ["X-Ide-Version-Type"] = "stable", + ["X-Device-Type"] = "macos", + ["X-OS-Version"] = "macOS 15.7.4", + ["X-Device-Brand"] = "Apple", + ["Request-Traffic-Type"] = "prod", + } + if uid ~= "" then h["X-Uid"] = uid end + if dev ~= "" then h["X-Device-Id"] = dev end + if mid ~= "" then h["X-Machine-Id"] = mid end + return h +end + local TRAE_DEFAULT_MODEL = "glm-5.2" local TRAE_FUNCTION = "solo_work_lite" local TRAE_MAX_MODE_TYPE = 1 @@ -9320,9 +9509,78 @@ function adapter.transform_response(raw_body) end function adapter.transform_stream_chunk(raw_chunk) - return __prelude.openai_stream_chunk(raw_chunk) -end + -- ★★ TRAE 是 **SOLO 自定义 SSE**,且内核要求适配器返回**扁平的 UnifiedChunk** + -- (`content` / `reasoning_content` / `usage` / `done` / `finish_reason`), + -- **不是** OpenAI 的 `{choices:[{delta:{...}}]}` 外壳。 + -- 见 __prelude.openai_stream_chunk 的实现:它把 choices[0].delta 摊平后返回。 + -- + -- 实测上游真实帧(2026-10-17 直接打 chat 端点抓包): + -- event:output → data:{"response":"###","reasoning_content":null,"tool_calls":null} + -- 正文在 **response**,推理在 **reasoning_content**(思考模式下先出 78 帧推理、 + -- 再出 40 帧正文)。 + -- + -- 真实缺陷(用户报「无法使用 channel 注册的模型」): + -- ① 我原先直接 `return __prelude.openai_stream_chunk(raw_chunk)` —— + -- SOLO 帧没有 `choices` ⇒ 每帧都解析不出内容 ⇒ `empty stream`。 + -- ② 修了 ① 之后我返回 OpenAI 外壳 `{choices:[{delta:{...}}]}` —— 仍不对: + -- 内核把整个 JSON 解码成 UnifiedChunk(扁平结构),外壳字段全被丢弃 ⇒ + -- 91 帧全是空 content。 + -- + -- ⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃), + -- 所以只能**按内容特征**判别帧类型。 + local ok, raw = pcall(json.decode, raw_chunk) + if not ok or type(raw) ~= "table" then return "" end + -- ① done:有 finish_reason、无正文 + if raw.finish_reason ~= nil and raw.response == nil then + return json.encode({ done = true, finish_reason = tostring(raw.finish_reason) }) + end + -- ② token_usage:整个对象是 usage + if raw.response == nil and raw.reasoning_content == nil and raw.tool_calls == nil + and (raw.prompt_tokens ~= nil or raw.completion_tokens ~= nil or raw.total_tokens ~= nil) then + return json.encode({ done = false, usage = { + prompt = tonumber(raw.prompt_tokens) or 0, + completion = tonumber(raw.completion_tokens) or 0, + total = tonumber(raw.total_tokens) or 0, + } }) + end + -- ③ output:正文 / 推理 / 工具调用(**扁平**输出) + local unified = { done = false } + local has = false + if type(raw.response) == "string" and raw.response ~= "" then + unified.content = raw.response + has = true + end + if type(raw.reasoning_content) == "string" and raw.reasoning_content ~= "" then + unified.reasoning_content = raw.reasoning_content + has = true + end + if type(raw.tool_calls) == "table" then + local tcs = {} + for _, c in ipairs(raw.tool_calls) do + if type(c) == "table" then + local copy = {} + for k, v in pairs(c) do copy[k] = v end + -- 参考 normalizeTraeToolCalls:function_call → function,清理 SOLO 专属字段 + if type(copy.function_call) == "table" and copy["function"] == nil then + copy["function"] = copy.function_call + copy.function_call = nil + end + if type(copy["function"]) == "table" then + copy["function"].namespace = nil + copy["function"].partial_arguments = nil + end + tcs[#tcs + 1] = copy + end + end + if #tcs > 0 then + unified.tool_calls = tcs + has = true + end + end + if not has then return "" end + return json.encode(unified) +end function adapter.transform_error(status, body) return __prelude.openai_error(status, body) end @@ -11457,7 +11715,43 @@ function plugin.__set_expiring_window_probe(args) return { json = { ok = true, days = expiringWindowDays() } } end --- plugin.__set_legacy_cred_probe:只给测试用 —— 造一条"升级前"的非池凭据。 + + +function plugin.__cred_headers_probe(args) + args = args or {} + local plat = platformById(args.platform) + if not plat then return { json = { error = "未知平台" } } end + local cred = {} + local okc, dec = pcall(json.decode, tostring(args.credential or "")) + if okc and type(dec) == "table" then cred = dec end + local tok = cred.access_token or "" + local out = {} + for _, which in ipairs({ "checkin_status", "balance" }) do + local spec = plat[which] + if spec then + local h = nil + local hfn = spec.headers_fn or plat.headers_fn + if hfn then + local okf, got = pcall(hfn, tok, cred) + if okf and type(got) == "table" then h = got end + end + if not h then h = { Authorization = "Bearer " .. tok, ["Content-Type"] = "application/json" } end + out[which] = h + end + end + return { json = out } +end + +function plugin.__set_pool_cred_probe(args) + args = args or {} + local plat = platformById(args.platform) + if not plat then return { json = { error = "未知平台" } } end + local ref = "channels:" .. plat.id .. ":" .. tostring(args.account_id or "") + local _, serr = __llmsproxy_secrets.set(ref, tostring(args.credential or "")) + if serr then return { json = { error = serr } } end + return { json = { ok = true, ref = ref } } +end + function plugin.__set_legacy_cred_probe(args) args = args or {} local ref = "channels:" .. tostring(args.platform or "") diff --git a/internal/provider/registry.go b/internal/provider/registry.go index a5c623d..6f2715b 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -117,13 +117,17 @@ func (r *Registry) Resolve(model string) []*Provider { // pinning prefix and returns the bare model id when that source serves it; // otherwise it returns the input unchanged. func (r *Registry) EffectiveModel(model string) string { - sep := strings.IndexAny(model, "-:/") - if sep < 1 || sep == len(model)-1 { + model = strings.TrimSpace(model) + if model == "" { return model } - src, m := model[:sep], model[sep+1:] r.mu.RLock() defer r.mu.RUnlock() + // 与 ResolvePinned 同一个拆分器:源名可含 `-`,不能用第一个分隔符切。 + src, m, ok := splitPinned(model, r.providers) + if !ok { + return model + } for _, p := range r.providers { if strings.EqualFold(p.Name(), src) && p.ModelByID(m) != nil { return m @@ -135,13 +139,16 @@ func (r *Registry) EffectiveModel(model string) string { // ResolvePinned resolves "source-model" / "source:model" / "source/model" to // the exact source, or nil if the source does not serve that model. func (r *Registry) ResolvePinned(model string) *Provider { - sep := strings.IndexAny(model, "-:/") - if sep < 1 || sep == len(model)-1 { + model = strings.TrimSpace(model) + if model == "" { return nil } - src, m := model[:sep], model[sep+1:] r.mu.RLock() defer r.mu.RUnlock() + src, m, ok := splitPinned(model, r.providers) + if !ok { + return nil + } for _, p := range r.providers { if !strings.EqualFold(p.Name(), src) { continue @@ -153,6 +160,54 @@ func (r *Registry) ResolvePinned(model string) *Provider { return nil } +// splitPinned 把 "源名 + 分隔符 + 模型名" 拆开。 +// +// ★★ 不能用 `IndexAny(model, "-:/")` 找**第一个**分隔符。 +// 源名本身常常含 `-`(本仓的渠道源是 `channels-<平台>-<账号id>`), +// 于是 `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 会被切成 +// +// src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro" +// +// ⇒ 源名永不匹配 ⇒ Resolve() 返回 nil ⇒ 客户端看到 +// `model "channels-trae-xxx:Doubao-Seed-2.1-Pro" is not configured`。 +// +// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17): +// WebUI 的模型下拉框用 `源名:模型名` 作为 option value(这是**有意**的: +// 44 个模型被多个源同时提供,必须能指定从哪个源调用),而网关解不开它。 +// +// 修法:**按已知源名做最长前缀匹配**,命中的那个源名才是真正的分隔点。 +// 这样既支持含 `-` 的源名,也保留 "src:model" / "src/model" 等旧写法。 +func splitPinned(model string, providers []*Provider) (src, m string, ok bool) { + // ① 最长源名前缀匹配(唯一可靠的办法:源名是已知集合) + best := "" + for _, p := range providers { + n := p.Name() + if n == "" || len(n) >= len(model) { + continue + } + if !strings.HasPrefix(strings.ToLower(model), strings.ToLower(n)) { + continue + } + // 源名之后必须紧跟一个分隔符 + if c := model[len(n)]; c != ':' && c != '/' && c != '-' { + continue + } + if len(n) > len(best) { + best = n + } + } + if best != "" { + return best, model[len(best)+1:], true + } + // ② 兜底:旧行为(第一个分隔符)。仅当没有任何源名匹配时才走到这里, + // 保持对非源名形态(例如第三方模型名自带 ":" 的)的兼容。 + sep := strings.IndexAny(model, "-:/") + if sep < 1 || sep == len(model)-1 { + return "", "", false + } + return model[:sep], model[sep+1:], true +} + // ProviderForModel returns the provider owning the model id (nil if unknown). // When the same model exists on multiple sources, returns the first (source // config order). Callers that need a specific source use ProviderForSlot. @@ -205,10 +260,10 @@ type ModelKindInfo struct { // ModelStatus is a web-UI friendly snapshot per source. type SourceStatus struct { - Name string `json:"name"` - Adapter string `json:"adapter"` - BaseURL string `json:"base_url"` - Models []string `json:"models"` + Name string `json:"name"` + Adapter string `json:"adapter"` + BaseURL string `json:"base_url"` + Models []string `json:"models"` // ModelKinds parallels Models: Models[i] is ModelKinds[i].ID. // // It exists because a flat []string cannot answer "is gpt-image-2 @@ -217,15 +272,15 @@ type SourceStatus struct { // callers that only need names; this is purely additive and omitted when // empty, so an older UI keeps working. ModelKinds []ModelKindInfo `json:"model_kinds,omitempty"` - Available bool `json:"available"` - Healthy bool `json:"healthy"` - MaxConcurrent int `json:"max_concurrent"` - LiveAvailable bool `json:"live_available"` - LastError string `json:"last_error,omitempty"` - LastChecked int64 `json:"last_checked,omitempty"` - FailCount int `json:"fail_count,omitempty"` - BackoffUntil int64 `json:"backoff_until,omitempty"` - Permanent bool `json:"permanent,omitempty"` + Available bool `json:"available"` + Healthy bool `json:"healthy"` + MaxConcurrent int `json:"max_concurrent"` + LiveAvailable bool `json:"live_available"` + LastError string `json:"last_error,omitempty"` + LastChecked int64 `json:"last_checked,omitempty"` + FailCount int `json:"fail_count,omitempty"` + BackoffUntil int64 `json:"backoff_until,omitempty"` + Permanent bool `json:"permanent,omitempty"` // RecentOK / RecentErr count real gateway requests served by this source // within the last 300s (drives the status column so a source that is // actually serving traffic can never be shown as down just because a diff --git a/internal/provider/registry_pinned_name_test.go b/internal/provider/registry_pinned_name_test.go new file mode 100644 index 0000000..68cf475 --- /dev/null +++ b/internal/provider/registry_pinned_name_test.go @@ -0,0 +1,98 @@ +package provider + +import "testing" + +// ★ `源名:模型名` 这种限定写法,源名本身**含 `-`** 时必须仍能解开。 +// +// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17): +// +// WebUI 的模型下拉框用 `源名:模型名` 作为 option value —— 这是**有意**的: +// 本实例有 44 个模型被多个源同时提供(glm-5.2 被 6 个源提供), +// 用户必须能指定「从哪个源调用」。 +// +// 但 ResolvePinned 用 `IndexAny(model, "-:/")` 找**第一个**分隔符, +// 而渠道源名形如 `channels-trae-b8dd6b3df30e`(含 `-`)⇒ 被切成 +// +// src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro" +// +// ⇒ 源名永不匹配 ⇒ Resolve() 返回 nil +// ⇒ 客户端看到 `model "channels-trae-xxx:..." is not configured`。 +func TestResolvePinnedWithHyphenatedSourceName(t *testing.T) { + cases := []struct { + name string + sources map[string][]string // 源名 → 模型 + input string + wantSrc string + wantMdl string + wantOK bool + }{ + { + name: "渠道源名含两个 -(真实形态)", + sources: map[string][]string{ + "channels-trae-b8dd6b3df30e": {"Doubao-Seed-2.1-Pro", "glm-5.2"}, + "channels-buddy-buddy-main": {"hy4-preview", "glm-5.2"}, + }, + input: "channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro", + wantSrc: "channels-trae-b8dd6b3df30e", + wantMdl: "Doubao-Seed-2.1-Pro", + wantOK: true, + }, + { + name: "同名模型跨源:必须能选中第二个源", + sources: map[string][]string{ + "channels-trae-b8dd6b3df30e": {"glm-5.2"}, + "channels-buddy-buddy-main": {"glm-5.2"}, + }, + input: "channels-buddy-buddy-main:glm-5.2", + wantSrc: "channels-buddy-buddy-main", + wantMdl: "glm-5.2", + wantOK: true, + }, + { + name: "斜杠写法仍要支持", + sources: map[string][]string{ + "channels-trae-b8dd6b3df30e": {"glm-5.2"}, + }, + input: "channels-trae-b8dd6b3df30e/glm-5.2", + wantSrc: "channels-trae-b8dd6b3df30e", + wantMdl: "glm-5.2", + wantOK: true, + }, + { + // 自带 ":" 的第三方模型名,且**没有**任何源名与之匹配 ⇒ 走旧兜底。 + // ⚠️ 不能拿 "our-free-model_cohere/..." 当例子:本实例真有一个叫 + // `our-free-model_cohere` 的源,而仓库里还有叫 `our` 的源 —— + // 最长前缀匹配会正确命中 `our`,那个例子的预期是我写错了。 + name: "无源名匹配时走旧兜底", + sources: map[string][]string{ + "channels-trae-b8dd6b3df30e": {"glm-5.2"}, + }, + input: "vendor-lib/thing:free", + wantSrc: "vendor", + wantMdl: "lib/thing:free", + wantOK: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var ps []*Provider + for name, models := range c.sources { + ps = append(ps, newTestProvider(t, src(name, "http://127.0.0.1:1", "openai", models...))) + } + src, m, ok := splitPinned(c.input, ps) + if ok != c.wantOK { + t.Fatalf("ok 应为 %v,实得 %v(输入 %q)", c.wantOK, ok, c.input) + } + if !ok { + return + } + if src != c.wantSrc { + t.Fatalf("★ 源名解析错:期望 %q,实得 %q\n"+ + " 输入 %q —— 源名含 `-` 时不能按第一个分隔符切", c.wantSrc, src, c.input) + } + if m != c.wantMdl { + t.Fatalf("模型名解析错:期望 %q,实得 %q", c.wantMdl, m) + } + }) + } +}