Compare commits

...

2 Commits

Author SHA1 Message Date
aff8354c8a fix(channels,provider): 渠道模型不可用 + 额度异常的三个根因
## 背景
用户报三问题:① channel 注册的模型无法使用;② 额度显示依旧异常;
③ 其他渠道应也存在类似问题。三条全部核实并给出结论(③ 见文档)。

## ① 网关解不开 `源名:模型名` 限定名(internal/provider/registry.go)

`ResolvePinned` 用 `strings.IndexAny(model, "-:/")` 找**第一个**分隔符。
而渠道源名形如 `channels-trae-b8dd6b3df30e`(**含 `-`**),于是
`channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 被切成

    src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro"

⇒ 源名永不匹配 ⇒ `Resolve()` 返回 nil
⇒ 客户端看到 `model "..." is not configured`。

**为什么这个写法必须支持**:本实例有 **44 个模型被多源同时提供**
(`glm-5.2` 由 6 个源提供),WebUI 的模型下拉框用 `源名:模型名` 作为
option value 是**有意设计** —— 用户必须能指定"从哪个源调用"。

修法:抽 `splitPinned()`,**按已知源名做最长前缀匹配**(源名是已知集合,
这是唯一可靠办法),保留旧 `-:/` 兜底以兼容非源名形态。
`EffectiveModel` 同步修(同一拆分规则,避免两处漂移)。

## ② trae 适配器三连错(internal/lua/plugins/channels.lua)

三个错误逐层叠加,每个都被上一层掩盖:

**(a) 没有 `build_headers`** ⇒ 内核兜底发 `Authorization: Bearer`,
而 trae 推理端点要 `Cloud-IDE-JWT` + 设备身份头 ⇒
401「We're sorry, but we are not able to authenticate you」。
→ 与上一轮「额度不可用」是**同一根因的两个出口**(额度端点修了、推理端点漏了)。
教训:修"某一族请求的鉴权"时,必须把该族的**所有出口**列出来逐个对齐。

**(b) header 常量的值是错的**(照参考 trae-product.ts 的 TRAE 配置逐项核对):
  · `X-App-Id` 抄成 `6eefa6c9`,真实是 `6eefa01c-1036-4c7e-9ca5-d891f63bfcd8`
  · `app_version_code` 用了 `0.1.52`,真实是**日期式** `20260811`
⇒ 上游 `4001 bad request: binding: expr_path=app_version_code,
   cause=parameter type does not match binding data`

**(c) 流式转换完全不对**:TRAE 是 **SOLO 自定义 SSE**:
    event:output → data:{"response":"###","reasoning_content":null,...}
正文在 `response`,推理在 `reasoning_content`。
  · 套 `__prelude.openai_stream_chunk` ⇒ 无 `choices` ⇒ `empty stream`
  · 改成 OpenAI 外壳 `{choices:[{delta}]}` **仍然空** —— 内核
    `json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构**,外壳字段全被丢弃
⇒ 必须输出扁平 `{content, reasoning_content, usage, done, finish_reason}`。

⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃),
   因此判别帧类型只能靠**内容特征**(`response` / `reasoning_content` /
   `finish_reason` / `prompt_tokens`),拿不到 event 名。

## ③ 余额恒为 0(同文件 balance.total_fn)

读的是 `p.credits_limit`(pack **顶层**)—— 该字段**根本不存在**。
真实路径是 `pack.entitlement_base_info.quota.credits_limit`
(参考 trae-credits.ts:341)+ `pack.usage.credits_amount`。
实测上游:`免费`(无该字段) / `每月登录赠送`(500) / `签到奖励`(100) ⇒ 实际 600。
修法:读嵌套路径 + 保留顶层兼容 + `usage_summary.{total,consumed}_amount` 兜底。
修后余额 600 → 实测 598.516(真实扣减)。

## ④ 夹具与生产形状不一致(internal/core/channels_trae_test.go)

夹具原先返回**扁平** `{"Token":...}`,与生产 `{"Result":{"Token":...}}` 不符
⇒ 「代码只读顶层」这个缺陷在夹具里**恰好是对的**,所有 trae 判据都绿。
**夹具形状错 = 判据必然虚假通过**。这是 ExchangeToken 缺陷藏了两轮的原因。

## 判据(新增,变异 4/4 咬住)
- `internal/provider/registry_pinned_name_test.go`(4 用例)
- `internal/core/channels_trae_chat_headers_test.go`(3 条)
- `internal/core/channels_trae_solo_sse_test.go`(2 条)
- `internal/core/channels_trae_credits_hdr_test.go`(1 条)
- `internal/core/channels_trae_exchange_shape_test.go`(2 条)

## 文档
`docs/channel-repair-plan.md`:现状快照(生产实测)+ 各渠道缺陷矩阵 +
后续计划(P0-P4)。**如实记录**:其余 4 个渠道(zcode/cline/gemini/qoder)
有同类缺陷风险,但**账号数均为 0、无法做生产验证**。

## 生产已实测
- `channels-trae-...:Doubao-Seed-2.1-Pro` → 80 帧,正文 27 + 推理 299
- `channels-trae-...:glm-5.2` / `channels-buddy-...:hy4-preview` → OK
- `credits/status` → active:true, dailyCredit:100, todayCheckedIn:true
- `credits/balances` → total:598.516 credits
2026-10-17 17:38:23 +08:00
091f790734 feat(channels): reap_orphan_sources —— 清理 v2.0.28 事故留下的存量孤儿源
## 发现路径:查"功能都正常了吗",逐源核对时发现的
生产 `config.yaml` 里有 21 个源,其中 `channels-buddy-585b335b64cf`
的账号 `585b335b64cf` **早已删除**(v2.0.23/24 的无凭据账号清理),
但它的源**仍在对外服务**:

- 独占提供 `hy4-preview-x` ⇒ 实测报 `404 Route Not Found`;
- 对 **6 个共享模型**则排在新源**前面**先被尝试(config 文件序即注册序)
  ⇒ 每次都撞 404 再回退,**每次白付一次上游往返**(实测 hy4-preview
  稳定 3.1~3.5s);
- 它的 base_url 还是 v2.0.30 之前的旧口径
  (`https://copilot.tencent.com/v2/chat/completions`,侵占了 endpoint 路径)。

**根因**:这正是 v2.0.28(`remove_source` 漏声明 ⇒ 撤源全被拒)日记里
那句"账号删了、它的源还在对外服务"的**存量残骸**。
v2.0.28 修了声明,**只防新增、不清理存量**。

**发现的一个助力**:v2.0.38 刚修好的错误文案让它暴露得更清楚 ——
客户端拿到的是 `404 Route Not Found`(APISIX 原文)而不是 `unknown error`。

## 修法
1. **立即清理**:走内核正式 API `DELETE /api/sources/<name>` 删掉该孤儿源
   (不手工改配置)。清理前备份配置,三重确认它确无对应账号。
2. **结构性修复**:新增 `reap_orphan_sources` 正式入口
   (`POST channels/reap_orphan_sources`,admin),识别
   `channels-<已知平台>-<账号id>` 且账号**不在**池中的源。

### 安全性(破坏性操作两条纪律,照 `reap_loopbacks` 先例)
- **只删该删的**:按**已知平台 id 前缀**逐个匹配。
  ⚠️ **不能用** `name:match("^channels%-(.+)-(.+)$")` —— Lua 的 `-` 是**贪婪**的,
  会把 `channels-buddy-buddy-main` 切成 plat="buddy-buddy"/acct="main"
  (账号 id 含 `-` 就错,而 `buddy-main` 就含)⇒ **孤儿漏杀**。
  运维手工配的源、旧版单账号源 `channels-<平台>` 一律不碰。
- **`dry_run` 默认开启**:不带 `dry_run=false` 只报告不删。

## 判据(`channels_orphan_source_test.go`,变异 2/2)
- 造出**真实形态**:`account_add` 建账号 → hidden 探针摘池(保留源),
  模拟"源留下"这个状态(不走 `account_delete`:那条路径会把源一并撤掉,
  而我们要的恰恰是它留下的形态)。
- 断言三件事:dry_run 只报告不删、真删后孤儿消失、**手工源全程存活**。
- **判别性用例**:第二个孤儿的账号 id 含 `-`(`dead-acct-1`)。
  这是唯一能区分"按已知平台前缀匹配"与"贪婪 match"的用例 ——
  实测不含 `-` 的 id 两种实现结果相同,**咬不住**(第一版就栽在这)。
- 变异:① `dry_run` 默认改 false ⇒ 报红;② 改回贪婪 match ⇒ 报红。

## ★ 过程中踩到的三个坑(都已记进代码注释)
1. **`Plugins.Call` 的参数是平铺的,不是 `req.body`** —— `bodyOf(req)` 返回空表,
   `dry_run=false` 被静默丢成默认 true ⇒ "真删"永远没发生。
   必须同时兜住两种调用形态(HTTP 路由走 body,插件 Call 平铺)。
2. **内核 `add_source` 取 `args["source"]`(嵌套一层)**,平铺字段被拒
   ("add_source requires a source table")。
3. **`config.Model` 要求 models 是对象数组 `{id=...}`**,传字符串数组被拒。
4. **判据只查 Go 侧 `err` 会静默通过**:内核把失败放在**返回体**里,
   Go `err` 仍是 nil ⇒ "源根本没注册"看起来像"没有孤儿"。
2026-10-17 08:34:21 +08:00
10 changed files with 1410 additions and 40 deletions

183
docs/channel-repair-plan.md Normal file
View File

@ -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:<jwt>`) |
| `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 <release HEAD>` 非空;`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:<jwt>`,我们的 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-<platform>-<accountId>`,只含 `-`/字母数字,安全。
### 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 代码变更时递增。

View File

@ -0,0 +1,169 @@
package core
import (
"testing"
)
// ★ 孤儿源必须能被识别、且**只删该删的**。
//
// 真实事故(2026-10-17 生产实测):`channels-buddy-585b335b64cf` 的账号早已删除,
// 源却仍在 config.yaml 里对外服务 —— 独占服务 hy4-preview-x(404),
// 对 6 个共享模型则排在正常源**前面**先被尝试(每次白付 ~3.4s 上游往返)。
//
// 这是 v2.0.28(remove_source 漏声明)的**存量残骸**:修声明只防新增,不清理存量。
func TestChannelsReapOrphanSources(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)
}
// 生产里 channels 被授予这些(见 plugin.kernel 声明);夹具必须同样授权,
// 否则 add_source 会被拒 ⇒ 造不出孤儿源。
if err := ps.SetPluginKernelPermission("channels",
[]string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil {
t.Fatalf("授权: %v", err)
}
// 装载前先清空账号池:这样插件初始化流程自己注册的源也一并算孤儿。
cleanup := func() interface{} {
r, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{
"dry_run": false,
})
if err != nil {
t.Fatalf("reap_orphan_sources: %v", err)
}
return r
}
cleanup()
// 造出**真实形态**:走 account_add 登记账号(它会注册源),
// 再用 hidden 探针把账号从池里摘掉 —— 模拟 v2.0.28 事故的残留形态
// (账号已删、撤源被拒 ⇒ 源留下继续对外服务)。
// 不走 account_delete:那条路径的收尾已被 TestAccountDelete* 覆盖,
// 而我们要的恰恰是"源留下了"这个状态。
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "deadbeef1234", "credential": "test-cred",
}); err != nil {
t.Fatalf("account_add: %v", err)
} else if j := jsonOf(t, r); j["error"] != nil {
t.Fatalf("account_add 报错: %v", j["error"])
}
// ★ 第二个孤儿:账号 id **含 `-`**。这个用例是判别性的 ——
// 用 `name:match("^channels%-(.+)-(.+)$")` 解析会被 Lua 的**贪婪** `-`
// 切成 plat="buddy-dead"/acct="acct"(未知平台)⇒ 源被跳过 ⇒ **孤儿漏杀**。
// (实测:只有这个用例能把"按已知平台前缀匹配"与"贪婪匹配"区分开;
// 不含 `-` 的 id 两种实现结果相同,咬不住。)
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "dead-acct-1", "credential": "test-cred",
}); err != nil {
t.Fatalf("account_add(2): %v", err)
} else if j := jsonOf(t, r); j["error"] != nil {
t.Fatalf("account_add(2) 报错: %v", j["error"])
}
// 运维手工源:名字与账号源形态无关 ⇒ 绝不碰
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
"name": "my-handmade-source", "base_url": "http://127.0.0.1:1",
"adapter": "openai", "models": []interface{}{"m1"},
}); err != nil {
t.Fatalf("注册手工源: %v", err)
} else if j := jsonOf(t, r); j["error"] != nil {
// ★ 必须看 json 里的 error:内核把失败放在返回体里,
// Go 侧 err 仍是 nil ⇒ 只查 err 会让"源根本没注册"静默通过。
t.Fatalf("注册手工源报错: %v", j["error"])
}
// 摘账号(保留源)
for _, id := range []string{"deadbeef1234", "dead-acct-1"} {
if _, err := ps.Call("channels", "__forget_account_probe", map[string]interface{}{
"platform": "buddy", "account_id": id,
}); err != nil {
t.Fatalf("摘账号 %s: %v", id, err)
}
}
// ① dry_run(默认)只报告、不删
res, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
j := jsonOf(t, res)
orphans, _ := j["orphans"].([]interface{})
seen := map[string]bool{}
for _, o := range orphans {
if s, _ := o.(string); s != "" {
seen[s] = true
}
}
for _, want := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
if !seen[want] {
t.Fatalf("★ 没识别出孤儿源 %s(dry_run 应报告它)\n"+
" 若 id 含 `-` 的那个漏了 ⇒ 说明用了贪婪 match 解析名字,应改为按已知平台前缀匹配\n"+
" 实得 %v", want, seen)
}
}
if seen["my-handmade-source"] {
t.Fatalf("★ 误伤运维手工源(名字不匹配账号源形态,绝不能碰)")
}
if j["dry_run"] != true {
t.Fatalf("dry_run 必须默认开启(破坏性操作不得默认生效),实得 %v", j["dry_run"])
}
if j["removed"].(float64) != 0 {
t.Fatalf("dry_run=true 时不得真删,实得 removed=%v", j["removed"])
}
// ② 确认 dry_run 真的没删(看源列表)
after, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
names := sourceNames(jsonOf(t, after))
if !names["channels-buddy-deadbeef1234"] {
t.Fatalf("dry_run 竟真删了!源列表: %v", names)
}
// 手工源必须存活(名字不匹配账号源形态 ⇒ 不该被识别为孤儿)
if !names["my-handmade-source"] {
t.Fatalf("dry_run 时就丢了手工源?源列表: %v", names)
}
// ③ 真删:孤儿消失、手工源存活
res, err = ps.Call("channels", "reap_orphan_sources", map[string]interface{}{"dry_run": false})
if err != nil {
t.Fatal(err)
}
j = jsonOf(t, res)
if j["ok"] != true {
t.Fatalf("清理应成功,实得 %v", j)
}
after, err = ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
names = sourceNames(jsonOf(t, after))
for _, dead := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
if names[dead] {
t.Fatalf("★ 孤儿源 %s 未被清掉(它会继续对外服务无凭据的请求): %v", dead, names)
}
}
if !names["my-handmade-source"] {
t.Fatalf("★ 误删了运维手工源: %v", names)
}
t.Logf("✓ 孤儿源已清、手工源存活")
}
// sourceNames 把 __list_sources_probe 的返回摊平成 name→true。
func sourceNames(j map[string]interface{}) map[string]bool {
out := map[string]bool{}
if arr, ok := j["sources"].([]interface{}); ok {
for _, x := range arr {
if m, ok := x.(map[string]interface{}); ok {
if s, ok := m["name"].(string); ok {
out[s] = true
}
}
}
}
return out
}

View File

@ -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 <api_key>`。
//
// 参考 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{}

View File

@ -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"])
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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", "__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 <token>`
-- ⇒ 上游回「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 <access_token>` ⇒ 上游 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()
@ -6955,6 +7034,8 @@ plugin.http = {
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
{ path = "channels/reap_loopbacks", method = "POST", handler = "reap_loopbacks",
roles = { "admin" }, description = "回收所有回环回调接收器(防测试/异常退出后泄漏)" },
{ path = "channels/reap_orphan_sources", method = "POST", handler = "reap_orphan_sources",
roles = { "admin" }, description = "清理孤儿源(账号已删但源仍在对外服务;默认 dry_run)" },
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
roles = { "admin" }, description = "回收服务端浏览器栈" },
}
@ -8413,6 +8494,116 @@ end
-- 测试残留 —— 生产路径的会被正常回收(因为生产会话真会过期)。
--
-- 对生产也有用:浏览器/授权页关掉后想立刻清理,不必等会话过期。
-- plugin.__add_source_for_test / __forget_account_probe:只给测试用。
--
-- 为什么要它们:孤儿源(账号已删、源留下)是 v2.0.28 事故的**存量形态**,
-- 而正常删除路径(account_delete)会把源一并撤掉 ⇒ 无法用它复现该状态。
-- 两个探针各自只做一件事,且都只在 __ 前缀(不暴露为 HTTP 路由)。
function plugin.__add_source_for_test(args)
args = args or {}
-- ⚠️ 内核 actionAddSource 取的是 args["source"](**嵌套一层**)——
-- 直接平铺字段会被拒("add_source requires a source table")。
-- 实测踩到:我的第一版写平铺,于是源根本没注册,而判据只看到"没有孤儿"。
-- ⚠️ 内核 config.Model 要求 models 是**对象数组** `{id=...}`,
-- 传字符串数组会被拒("source shape is invalid: cannot unmarshal string
-- into Source.models.0")。归一化,免得调用方要懂内核结构。
local ms = {}
for _, m in ipairs(args.models or {}) do
if type(m) == "table" then ms[#ms + 1] = m else ms[#ms + 1] = { id = m } end
end
local r, err = __llmsproxy_kernel.call("add_source", {
source = {
name = args.name, base_url = args.base_url, adapter = args.adapter,
models = ms,
},
})
if r == nil or r.ok ~= true then
return { json = { error = tostring(err or "add_source 失败") } }
end
return { json = { ok = true } }
end
function plugin.__forget_account_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
poolRemove(plat.id, args.account_id)
return { json = { ok = true } }
end
-- reap_orphan_sources:清理**孤儿源** —— 名为 `channels-<平台>-<账号id>`
-- 但账号池里**已无**该账号的源。
--
-- ★ 为什么需要它(真实事故,2026-10-17 于生产实测):
-- v2.0.28 修好了 `remove_source` 漏声明,但**修复之前**凡是走过
-- `account_delete` 的账号,它的源都因为撤源被拒而**留了下来**。
-- 孤儿源没有对应账号 ⇒ 没凭据 ⇒ 每次调用必然失败,但它们**仍在对外服务**:
-- 实测 `channels-buddy-585b335b64cf`(账号早已删除)仍在 config.yaml 里,
-- 独占提供 `hy4-preview-x`,对 6 个共享模型则排在新源**前面**先被尝试 ——
-- 每次都撞 404 再回退(实测每次白付 ~3.1-3.5s 上游往返)。
-- ⇒ 这正是 v2.0.28 那句"账号删了、它的源还在对外服务"的**存量残骸**:
-- 修声明只防新增,**不清理存量**。故需要这个显式一次性入口。
--
-- ★ 安全性(破坏性操作的两条纪律,照 reap_loopbacks 的先例):
-- ① **只删该删的**:名字必须**严格**匹配某个**已知平台**的
-- `channels-<平台>-<账号id>`,且该账号确实不在池中。
-- ⚠️ 不能用 `name:match("^channels%-(.+)-(.+)$")` —— Lua 的 `-` 是**贪婪**的,
-- 会把 `channels-buddy-buddy-main` 切成 plat="buddy-buddy"/acct="main"
-- (账号 id 里含 `-` 就错,而 buddy-main 就含)。按已知平台前缀逐个匹配。
-- 运维手工配的源(任何其它名字)一律不碰;旧版单账号源 `channels-<平台>`
-- 也不碰(那是 account_delete 的收尾职责,且可能有意保留作兼容)。
-- ② **dry_run 默认开启**:不带 `dry_run=false` 只报告不删。
function plugin.reap_orphan_sources(req)
-- ⚠️ 两种调用形态都要兜住(本仓库的既有约定,见 account_add 等):
-- * HTTP 路由:参数在 req.body(JSON 字符串)
-- * 插件 Call(测试/内部):参数**直接就是** req,bodyOf 会返回空表
-- ⇒ 只读 bodyOf 会把 `dry_run=false` 静默丢成默认的 true(**实测评据
-- 就是这么失败的**:dry_run 永远是 true,所以"真删"根本没发生)。
local args = bodyOf(req) or {}
if args.dry_run == nil then args = req or {} end
local dry = args.dry_run
if dry == nil then dry = true end
if dry ~= false then dry = true end
local ls = __llmsproxy_kernel.call("list_sources", {})
local arr = ls and ls.json or ls
if type(arr) ~= "table" then arr = {} end
local found, removed, failed = {}, 0, {}
for _, x in ipairs(arr) do
local nm = type(x) == "table" and x.name or nil
if type(nm) == "string" then
local plat, acct = nil, nil
for _, p in ipairs(PLATFORMS) do
local pref = "channels-" .. p.id .. "-"
if nm:sub(1, #pref) == pref then
plat, acct = p.id, nm:sub(#pref + 1)
break
end
end
if plat and acct ~= "" and poolGet(plat, acct) == nil then
found[#found + 1] = nm
if not dry then
local r, err = __llmsproxy_kernel.call("remove_source", { name = nm })
if r == nil or r.ok ~= true then
failed[#failed + 1] = nm .. ": " .. tostring(err or "(无错误信息)")
logEvent("孤儿源清理失败 " .. nm .. ": " .. tostring(err or "(无错误信息)"),
"error", plat)
else
removed = removed + 1
logEvent("孤儿源已清理 " .. nm .. "(账号 " .. acct .. " 已不在池中)", "warn", plat)
end
end
end
end
end
return { status = 200, json = {
ok = #failed == 0, dry_run = dry,
orphan_count = #found, orphans = found,
removed = removed, failed = failed,
} }
end
function plugin.reap_loopbacks(req)
local n = 0
for sid in pairs(plugin.state.loopbacks or {}) do
@ -8695,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 兜底
@ -8883,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
@ -9036,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
@ -9074,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 <api_key>`
-- ⇒ 上游 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
@ -9208,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
@ -11345,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 "")

View File

@ -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

View File

@ -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)
}
})
}
}