mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aff8354c8a | |||
| 091f790734 |
183
docs/channel-repair-plan.md
Normal file
183
docs/channel-repair-plan.md
Normal 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 代码变更时递增。
|
||||
169
internal/core/channels_orphan_source_test.go
Normal file
169
internal/core/channels_orphan_source_test.go
Normal 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
|
||||
}
|
||||
117
internal/core/channels_trae_chat_headers_test.go
Normal file
117
internal/core/channels_trae_chat_headers_test.go
Normal 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{}
|
||||
71
internal/core/channels_trae_credits_hdr_test.go
Normal file
71
internal/core/channels_trae_credits_hdr_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
124
internal/core/channels_trae_exchange_shape_test.go
Normal file
124
internal/core/channels_trae_exchange_shape_test.go
Normal 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
|
||||
}
|
||||
130
internal/core/channels_trae_solo_sse_test.go
Normal file
130
internal/core/channels_trae_solo_sse_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 "")
|
||||
|
||||
@ -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
|
||||
|
||||
98
internal/provider/registry_pinned_name_test.go
Normal file
98
internal/provider/registry_pinned_name_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user